From 6597bf7eca564012c13ab41336452d6bf859b22f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 8 May 2025 13:27:53 -0500 Subject: [PATCH 001/581] wip --- Examples/Examples.xcodeproj/project.pbxproj | 8 +- Examples/Reminders/ReminderForm.swift | 6 +- Examples/Reminders/ReminderRow.swift | 4 +- Examples/Reminders/Reminders.entitlements | 16 ++ Examples/Reminders/RemindersDetail.swift | 10 +- Examples/Reminders/RemindersListRow.swift | 2 +- Examples/Reminders/RemindersLists.swift | 6 +- Examples/Reminders/Schema.swift | 208 ++++++++++++++---- Examples/Reminders/TagRow.swift | 2 +- Examples/Reminders/TagsForm.swift | 2 +- Package.swift | 3 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- Tests/SharingGRDBTests/FetchTests.swift | 17 ++ 13 files changed, 218 insertions(+), 77 deletions(-) create mode 100644 Examples/Reminders/Reminders.entitlements diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index d3c6a173..465f5650 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -710,9 +710,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Reminders/Reminders.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Reminders/Preview Content\""; + DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -725,7 +727,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Reminders; + PRODUCT_BUNDLE_IDENTIFIER = "co.pointfree.sharing-grdb.Reminders"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -737,9 +739,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Reminders/Reminders.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Reminders/Preview Content\""; + DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -752,7 +756,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Reminders; + PRODUCT_BUNDLE_IDENTIFIER = "co.pointfree.sharing-grdb.Reminders"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 6f94e7ad..1891215e 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -137,7 +137,7 @@ struct ReminderFormView: View { try Tag .order(by: \.title) .join(ReminderTag.all) { $0.id.eq($1.tagID) } - .where { $1.reminderID.eq(reminderID) } + .where { $1.reminderID.eq(#bind(reminderID)) } .select { tag, _ in tag } .fetchAll(db) } @@ -173,7 +173,7 @@ struct ReminderFormView: View { try database.write { db in let reminderID = try Reminder.upsert(reminder).returning(\.id).fetchOne(db)! try ReminderTag - .where { $0.reminderID.eq(reminderID) } + .where { $0.reminderID.eq(#bind(reminderID)) } .delete() .execute(db) try ReminderTag.insert( @@ -210,7 +210,7 @@ struct ReminderFormPreview: PreviewProvider { let remindersList = try RemindersList.all.fetchOne(db)! return ( remindersList, - try Reminder.where { $0.remindersListID == remindersList.id }.fetchOne(db)! + try Reminder.where { $0.remindersListID.eq(#bind(remindersList.id)) }.fetchOne(db)! ) } } diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index 479a420f..ad604b08 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -86,7 +86,7 @@ struct ReminderRow: View { withErrorReporting { try database.write { db in try Reminder - .find(reminder.id) + .find(UUID.LowercasedRepresentation(queryOutput: reminder.id)) .update { $0.isFlagged.toggle() } .execute(db) } @@ -129,7 +129,7 @@ struct ReminderRow: View { try database.write { db in isCompleted = try Reminder - .find(reminder.id) + .find(UUID.LowercasedRepresentation(queryOutput: reminder.id)) .update { $0.isCompleted.toggle() } .returning(\.isCompleted) .fetchOne(db) ?? isCompleted diff --git a/Examples/Reminders/Reminders.entitlements b/Examples/Reminders/Reminders.entitlements new file mode 100644 index 00000000..3bbe264c --- /dev/null +++ b/Examples/Reminders/Reminders.entitlements @@ -0,0 +1,16 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.co.pointfree.sharing-grdb.Reminders + + com.apple.developer.icloud-services + + CloudKit + + + diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 94b702de..6d1c6464 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -135,14 +135,14 @@ struct RemindersDetailView: View { var ids = reminderStates.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) try Reminder - .where { $0.id.in(ids) } + .where { $0.id.in(ids.map { #bind($0) }) } .update { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) $0.position = rest - .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in - cases.when(id.element, then: id.offset) + .reduce(Case($0.id).when(#bind(first.element), then: first.offset)) { cases, id in + cases.when(#bind(id.element), then: id.offset) } .else($0.position) } @@ -206,9 +206,9 @@ struct RemindersDetailView: View { case .all: !reminder.isCompleted case .completed: reminder.isCompleted case .flagged: reminder.isFlagged - case .list(let list): reminder.remindersListID.eq(list.id) + case .list(let list): reminder.remindersListID.eq(#bind(list.id)) case .scheduled: reminder.isScheduled - case .tags(let tags): tag.id.ifnull(0).in(tags.map(\.id)) + case .tags(let tags): tag.id.ifnull(#bind(UUID(0))).in(tags.map { #bind($0.id) }) case .today: reminder.isToday } } diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 553879a9..4a6bdc87 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -56,7 +56,7 @@ struct RemindersListRow: View { RemindersListRow( remindersCount: 10, remindersList: RemindersList( - id: 1, + id: UUID(1), title: "Personal" ) ) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index e9e6a241..ccd3de58 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -221,14 +221,14 @@ struct RemindersListsView: View { var ids = remindersLists.map(\.remindersList.id) ids.move(fromOffsets: source, toOffset: destination) try RemindersList - .where { $0.id.in(ids) } + .where { $0.id.in(ids.map { #bind($0) }) } .update { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) $0.position = rest - .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in - cases.when(id.element, then: id.offset) + .reduce(Case($0.id).when(#bind(first.element), then: first.offset)) { cases, id in + cases.when(#bind(id.element), then: id.offset) } .else($0.position) } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index bfd7d26d..92528bae 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -6,7 +6,8 @@ import SwiftUI @Table struct RemindersList: Hashable, Identifiable { - var id: Int + @Column(as: UUID.LowercasedRepresentation.self) + var id: UUID @Column(as: Color.HexRepresentation.self) var color = Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255) var position = 0 @@ -15,14 +16,16 @@ struct RemindersList: Hashable, Identifiable { @Table struct Reminder: Equatable, Identifiable { - var id: Int + @Column(as: UUID.LowercasedRepresentation.self) + var id: UUID @Column(as: Date.ISO8601Representation?.self) var dueDate: Date? var isCompleted = false var isFlagged = false var notes = "" var priority: Priority? - var remindersListID: Int + @Column(as: UUID.LowercasedRepresentation.self) + var remindersListID: RemindersList.ID var position = 0 var title = "" } @@ -63,7 +66,8 @@ enum Priority: Int, QueryBindable { @Table struct Tag: Hashable, Identifiable { - var id: Int + @Column(as: UUID.LowercasedRepresentation.self) + var id: UUID var title = "" } @@ -81,7 +85,9 @@ extension Tag.TableColumns { @Table("remindersTags") struct ReminderTag: Hashable, Identifiable { + @Column(as: UUID.LowercasedRepresentation.self) var reminderID: Reminder.ID + @Column(as: UUID.LowercasedRepresentation.self) var tagID: Tag.ID var id: Self { self } } @@ -117,7 +123,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT PRIMARY KEY DEFAULT (uuid()), "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), "title" TEXT NOT NULL ) STRICT @@ -127,13 +133,13 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT PRIMARY KEY DEFAULT (uuid()), "dueDate" TEXT, "isCompleted" INTEGER NOT NULL DEFAULT 0, "isFlagged" INTEGER NOT NULL DEFAULT 0, "notes" TEXT, "priority" INTEGER, - "remindersListID" INTEGER NOT NULL, + "remindersListID" TEXT NOT NULL, "title" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE @@ -144,7 +150,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "tags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT PRIMARY KEY DEFAULT (uuid()), "title" TEXT NOT NULL COLLATE NOCASE UNIQUE ) STRICT """ @@ -153,8 +159,8 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersTags" ( - "reminderID" INTEGER NOT NULL, - "tagID" INTEGER NOT NULL, + "reminderID" TEXT NOT NULL, + "tagID" TEXT NOT NULL, FOREIGN KEY("reminderID") REFERENCES "reminders"("id") ON DELETE CASCADE, FOREIGN KEY("tagID") REFERENCES "tags"("id") ON DELETE CASCADE @@ -231,9 +237,112 @@ func appDatabase() throws -> any DatabaseWriter { #endif try migrator.migrate(database) + try database.write { db in + try installTriggers(db: db) + } + + /* + prepareDependencies { + $0.cloudKitDatabase = … + } + + @Dependency(\.cloudKitDatabase) var cloudKitDatabase + try cloudKitDatabase.registerTriggers(db) + + let tableNames = select name from sqlite_master where type = 'table'; + for tableName in tablesNames { + CREATE TRIGGER "\(tableName)_insert_trigger" + AFTER INSERT ON "\(tableName)" FOR EACH ROW BEGIN + SELECT insertTrigger('\(tableName)', new.id) + END + } +} + */ + return database } +func installTriggers(db: Database) throws { + db.add(function: DatabaseFunction.init("didInsert", function: { arguments in + logger.info("didInsert: \(arguments[0]).\(arguments[1])") + return 0 + })) + db.add(function: DatabaseFunction.init("didUpdate", function: { arguments in + logger.info("didUpdate: \(arguments[0]).\(arguments[1])") + return 0 + })) + db.add(function: DatabaseFunction.init("didDelete", function: { arguments in + logger.info("didDelete: \(arguments[0]).\(arguments[1])") + return 0 + })) + let tableNames = try #sql( + """ + SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' + """, + as: String.self + ) + .fetchAll(db) + .filter { !$0.hasPrefix("sqlite_") && !$0.hasPrefix("grdb_") } + + for tableName in tableNames { + try #sql( + """ + DROP TRIGGER IF EXISTS "__\(raw: tableName)_sync_inserts" + """ + ) + .execute(db) + try #sql( + """ + DROP TRIGGER IF EXISTS "__\(raw: tableName)_sync_updates" + """ + ) + .execute(db) + try #sql( + """ + DROP TRIGGER IF EXISTS "__\(raw: tableName)_sync_deletes" + """ + ) + .execute(db) +// // TODO: what about tables without 'id'? + try #sql( + """ + CREATE TRIGGER "__\(raw: tableName)_sync_inserts" + AFTER INSERT ON "\(raw: tableName)" FOR EACH ROW BEGIN + SELECT didInsert('\(raw: tableName)', new.rowid); + END + """ + ) + .execute(db) + try #sql( + """ + CREATE TRIGGER "__\(raw: tableName)_sync_updates" + AFTER UPDATE ON "\(raw: tableName)" FOR EACH ROW BEGIN + SELECT didUpdate('\(raw: tableName)', new.rowid); + END + """ + ) + .execute(db) + try #sql( + """ + CREATE TRIGGER "__\(raw: tableName)_sync_deletes" + BEFORE DELETE ON "\(raw: tableName)" FOR EACH ROW BEGIN + SELECT didDelete('\(raw: tableName)', old.rowid); + END + """ + ) + .execute(db) + } +} + +// +//func insertTrigger(tableName: String, id: UUID) { +// @Dependency(\.cloudKitDatabase) var db +// db.add(pendingRecordZoneChanges: .saveRecord(CKRecord.ID(zoneID: tableName, recordName: id))) +//} +//func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? { +//} +// + private let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG @@ -241,112 +350,115 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") func seedSampleData() throws { try seed { RemindersList( - id: 1, + id: UUID(1), color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), title: "Personal" ) RemindersList( - id: 2, + id: UUID(2), color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), title: "Family" ) RemindersList( - id: 3, + id: UUID(3), color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), title: "Business" ) + Reminder( - id: 1, + id: UUID(1), notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - remindersListID: 1, + remindersListID: UUID(1), title: "Groceries" ) Reminder( - id: 2, + id: UUID(2), dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isFlagged: true, - remindersListID: 1, + remindersListID: UUID(1), title: "Haircut" ) Reminder( - id: 3, + id: UUID(3), dueDate: Date(), notes: "Ask about diet", priority: .high, - remindersListID: 1, + remindersListID: UUID(1), title: "Doctor appointment" ) Reminder( - id: 4, + id: UUID(4), dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), isCompleted: true, - remindersListID: 1, + remindersListID: UUID(1), title: "Take a walk" ) Reminder( - id: 5, + id: UUID(5), dueDate: Date(), - remindersListID: 1, + remindersListID: UUID(1), title: "Buy concert tickets" ) Reminder( - id: 6, + id: UUID(6), dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), isFlagged: true, priority: .high, - remindersListID: 2, + remindersListID: UUID(2), title: "Pick up kids from school" ) Reminder( - id: 7, + id: UUID(7), dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .low, - remindersListID: 2, + remindersListID: UUID(2), title: "Get laundry" ) Reminder( - id: 8, + id: UUID(8), dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), isCompleted: false, priority: .high, - remindersListID: 2, + remindersListID: UUID(2), title: "Take out trash" ) Reminder( - id: 9, + id: UUID(9), dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), notes: """ Status of tax return Expenses for next year Changing payroll company """, - remindersListID: 3, + remindersListID: UUID(3), title: "Call accountant" ) Reminder( - id: 10, + id: UUID(10), dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .medium, - remindersListID: 3, + remindersListID: UUID(3), title: "Send weekly emails" ) - Tag(id: 1, title: "car") - Tag(id: 2, title: "kids") - Tag(id: 3, title: "someday") - Tag(id: 4, title: "optional") - Tag(id: 5, title: "social") - Tag(id: 6, title: "night") - Tag(id: 7, title: "adulting") - ReminderTag(reminderID: 1, tagID: 3) - ReminderTag(reminderID: 1, tagID: 4) - ReminderTag(reminderID: 1, tagID: 7) - ReminderTag(reminderID: 2, tagID: 3) - ReminderTag(reminderID: 2, tagID: 4) - ReminderTag(reminderID: 3, tagID: 7) - ReminderTag(reminderID: 4, tagID: 1) - ReminderTag(reminderID: 4, tagID: 2) + + Tag(id: UUID(1), title: "car") + Tag(id: UUID(2), title: "kids") + Tag(id: UUID(3), title: "someday") + Tag(id: UUID(4), title: "optional") + Tag(id: UUID(5), title: "social") + Tag(id: UUID(6), title: "night") + Tag(id: UUID(7), title: "adulting") + + ReminderTag(reminderID: UUID(1), tagID: UUID(3)) + ReminderTag(reminderID: UUID(1), tagID: UUID(4)) + ReminderTag(reminderID: UUID(1), tagID: UUID(7)) + ReminderTag(reminderID: UUID(2), tagID: UUID(3)) + ReminderTag(reminderID: UUID(2), tagID: UUID(4)) + ReminderTag(reminderID: UUID(3), tagID: UUID(7)) + ReminderTag(reminderID: UUID(4), tagID: UUID(1)) + ReminderTag(reminderID: UUID(4), tagID: UUID(2)) } } } diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index ae9c7e27..ffe38591 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -34,7 +34,7 @@ struct TagRow: View { #Preview { NavigationStack { List { - TagRow(tag: Tag(id: 1, title: "optional")) + TagRow(tag: Tag(id: UUID(1), title: "optional")) } } } diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 8ba5b1a1..96b2413a 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -56,7 +56,7 @@ struct TagsView: View { let rest = try Tag - .where { !$0.id.in(top.map(\.id)) } + .where { !$0.id.in(top.map { #bind($0.id) }) } .order(by: \.title) .fetchAll(db) diff --git a/Package.swift b/Package.swift index 516fff8e..c851091e 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,8 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.2.0"), + //.package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.2.0"), + .package(path: "../swift-structured-queries") ], targets: [ .target( diff --git a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 99749b44..9ba12357 100644 --- a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ec7a0ec47976760149a8edf8f7d5f19c9e9ff83c418b7952e6dc20d8e1416329", + "originHash" : "cb356621f2478d22ec989416bb5cdd7d9e8c09843c438a8f0f1244eede1c12ef", "pins" : [ { "identity" : "combine-schedulers", @@ -136,15 +136,6 @@ "version" : "1.18.3" } }, - { - "identity" : "swift-structured-queries", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-structured-queries", - "state" : { - "revision" : "71657e2f1d5b5af29e8cc5c450a67523433671b1", - "version" : "0.2.0" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Tests/SharingGRDBTests/FetchTests.swift b/Tests/SharingGRDBTests/FetchTests.swift index 706cdea2..92bb0382 100644 --- a/Tests/SharingGRDBTests/FetchTests.swift +++ b/Tests/SharingGRDBTests/FetchTests.swift @@ -50,3 +50,20 @@ extension DatabaseWriter where Self == DatabaseQueue { return database } } + +@Test(.dependency(\.defaultDatabase, try .database())) func foo() throws { + @Dependency(\.defaultDatabase) var defaultDatabase + try defaultDatabase.write { db in + db.add(function: DatabaseFunction.init("didInsert", function: { arguments in + return 0 + })) + } + + try defaultDatabase.write { db in + try #sql(""" + select didInsert(1) + """) + .execute(db) + } +} +import Foundation From e78d285b59c1a2100cc4ab6b76d12a097433c7b8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 8 May 2025 16:09:52 -0500 Subject: [PATCH 002/581] wip --- Examples/Reminders/CloudKitDatabase.swift | 213 +++++++++++++++++++++ Examples/Reminders/ReminderForm.swift | 7 +- Examples/Reminders/RemindersApp.swift | 6 + Examples/Reminders/RemindersListForm.swift | 2 +- Examples/Reminders/Schema.swift | 202 ++++++++++--------- 5 files changed, 332 insertions(+), 98 deletions(-) create mode 100644 Examples/Reminders/CloudKitDatabase.swift diff --git a/Examples/Reminders/CloudKitDatabase.swift b/Examples/Reminders/CloudKitDatabase.swift new file mode 100644 index 00000000..32be227b --- /dev/null +++ b/Examples/Reminders/CloudKitDatabase.swift @@ -0,0 +1,213 @@ +import CloudKit +import Dependencies +import SharingGRDB + +extension DependencyValues { + var cloudKitDatabase: CloudKitDatabase { + get { + self[CloudKitDatabase.self] + } + set { + self[CloudKitDatabase.self] = newValue + } + } +} +extension CloudKitDatabase: TestDependencyKey { + static var testValue: CloudKitDatabase { + if shouldReportUnimplemented { + reportIssue("TODO") + } + return CloudKitDatabase(container: CKContainer(identifier: "default")) + } +} + +// TODO: fix sendable by either making actor or locking mutable state +class CloudKitDatabase: @unchecked Sendable { + let container: CKContainer + let syncEngine: CKSyncEngine + var stateSerialization: CKSyncEngine.State.Serialization? + let delegate = Delegate() + + init(container: CKContainer) { + self.container = container + + var configuration = CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: stateSerialization, + delegate: delegate + ) + configuration.automaticallySync = true + syncEngine = CKSyncEngine(configuration) + } + + func saveZones(tableNames: [String]) { + syncEngine.state.add( + pendingDatabaseChanges: tableNames.map { .saveZone(CKRecordZone(zoneName: $0)) } + ) + } + + func didInsert(tableName: String, id: String) { + syncEngine.state.add( + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: id, + zoneID: CKRecordZone(zoneName: tableName).zoneID + ) + ) + ] + ) + } + + func didUpdate(tableName: String, id: String) { + // TODO: perform modification date checks + syncEngine.state.add( + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: id, + zoneID: CKRecordZone(zoneName: tableName).zoneID + ) + ) + ] + ) + } + + func willDelete(tableName: String, id: String) { + syncEngine.state.add( + pendingRecordZoneChanges: [ + .deleteRecord( + CKRecord.ID( + recordName: id, + zoneID: CKRecordZone(zoneName: tableName).zoneID + ) + ) + ] + ) + +// let contacts = ids.compactMap { self.appData.contacts[$0] } +// for id in ids { +// self.appData.contacts[id] = nil +// } +// try self.persistLocalData() +// +// let pendingDeletions: [CKSyncEngine.PendingRecordZoneChange] = contacts.map { .deleteRecord($0.recordID) } +// self.syncEngine.state.add(pendingRecordZoneChanges: pendingDeletions) + + } +} + +final class Delegate: CKSyncEngineDelegate { + + func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + logger.info("CloudKitDatabase.Delegate.handleEvent.\(event)") + switch event { + case .stateUpdate(_): + // TODO + break + case .accountChange(_): + // TODO + break + case .fetchedDatabaseChanges(_): + // TODO + break + case .fetchedRecordZoneChanges(_): + // TODO + break + case .sentDatabaseChanges(_): + // TODO + break + case .sentRecordZoneChanges(_): + // TODO + break + case .willFetchChanges(_): + // TODO + break + case .willFetchRecordZoneChanges(_): + // TODO + break + case .didFetchRecordZoneChanges(_): + // TODO + break + case .didFetchChanges(_): + // TODO + break + case .willSendChanges(_): + // TODO + break + case .didSendChanges(_): + // TODO + break + @unknown default: + // TODO + break + } + } + + func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + logger.info("CloudKitDatabase.Delegate.nextRecordZoneChangeBatch \(context)") + + let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) + let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in + let primaryKey = recordID.recordName + let tableName = recordID.zoneID.zoneName + + // TODO: fetch record data from centralized table + let record = CKRecord(recordType: tableName, recordID: recordID) + + @Dependency(\.defaultDatabase) var database + let row = withErrorReporting { + try database.read { db in + try Row.fetchOne( + db, + SQLRequest( + sql: """ + SELECT * FROM "\(tableName)" WHERE "id" = ? + """, + arguments: [primaryKey] + ) + ) + } + } + + guard + let row, // No error was thrown from fetchOne + let row // fetchOne returned a value + else { + syncEngine.state.remove(pendingRecordZoneChanges: [ .saveRecord(recordID) ]) + return nil + } + + for columnName in row.columnNames { + switch row[columnName]?.databaseValue.storage { + case .null: + record.encryptedValues[columnName] = nil + case .int64(let value): + record.encryptedValues[columnName] = value + case .double(let value): + record.encryptedValues[columnName] = value + case .string(let value): + record.encryptedValues[columnName] = value + case .blob(let value): + record.encryptedValues[columnName] = value + case .none: + break + } + } + // TODO: save new record in centralized table + + return record + } + return batch + } + + // func nextFetchChangesOptions( + // _ context: CKSyncEngine.FetchChangesContext, + // syncEngine: CKSyncEngine + // ) async -> CKSyncEngine.FetchChangesOptions { + // + // } +} diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 1891215e..4c93b4ba 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -18,7 +18,10 @@ struct ReminderFormView: View { if let existingReminder { reminder = Reminder.Draft(existingReminder) } else { - reminder = Reminder.Draft(remindersListID: remindersList.id) + var reminder = Reminder.Draft(remindersListID: remindersList.id) + // TODO: better way to handle default UUID? + reminder.id = UUID() + self.reminder = reminder } } @@ -178,7 +181,7 @@ struct ReminderFormView: View { .execute(db) try ReminderTag.insert( selectedTags.map { tag in - ReminderTag(reminderID: reminderID, tagID: tag.id) + ReminderTag(id: UUID(), reminderID: reminderID, tagID: tag.id) } ) .execute(db) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 57443a92..66787994 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,3 +1,4 @@ +import CloudKit import SharingGRDB import SwiftUI @@ -5,6 +6,11 @@ import SwiftUI struct RemindersApp: App { init() { try! prepareDependencies { + $0.cloudKitDatabase = CloudKitDatabase( + container: CKContainer( + identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" + ) + ) $0.defaultDatabase = try Reminders.appDatabase() } } diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 37bb2acc..448491d0 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -9,7 +9,7 @@ struct RemindersListForm: View { @Environment(\.dismiss) var dismiss init(existingList: RemindersList.Draft? = nil) { - remindersList = existingList ?? RemindersList.Draft() + remindersList = existingList ?? RemindersList.Draft(id: UUID()) } var body: some View { diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 92528bae..afd22fdc 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -85,18 +85,20 @@ extension Tag.TableColumns { @Table("remindersTags") struct ReminderTag: Hashable, Identifiable { + @Column(as: UUID.LowercasedRepresentation.self) + var id: UUID + @Column(as: UUID.LowercasedRepresentation.self) var reminderID: Reminder.ID @Column(as: UUID.LowercasedRepresentation.self) var tagID: Tag.ID - var id: Self { self } } func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() - configuration.foreignKeysEnabled = true + //configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in #if DEBUG db.trace(options: .profile) { @@ -159,6 +161,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersTags" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "reminderID" TEXT NOT NULL, "tagID" TEXT NOT NULL, @@ -228,6 +231,9 @@ func appDatabase() throws -> any DatabaseWriter { ) .execute(db) } + + + #if DEBUG && targetEnvironment(simulator) if context != .test { migrator.registerMigration("Seed sample data") { db in @@ -241,109 +247,115 @@ func appDatabase() throws -> any DatabaseWriter { try installTriggers(db: db) } - /* - prepareDependencies { - $0.cloudKitDatabase = … - } - - @Dependency(\.cloudKitDatabase) var cloudKitDatabase - try cloudKitDatabase.registerTriggers(db) - - let tableNames = select name from sqlite_master where type = 'table'; - for tableName in tablesNames { - CREATE TRIGGER "\(tableName)_insert_trigger" - AFTER INSERT ON "\(tableName)" FOR EACH ROW BEGIN - SELECT insertTrigger('\(tableName)', new.id) - END - } -} - */ - return database } +// TODO: can cloudKitDatabase be created in here and captured in DatabaseFunctions? does any part of the app need access to it? func installTriggers(db: Database) throws { - db.add(function: DatabaseFunction.init("didInsert", function: { arguments in - logger.info("didInsert: \(arguments[0]).\(arguments[1])") - return 0 - })) - db.add(function: DatabaseFunction.init("didUpdate", function: { arguments in - logger.info("didUpdate: \(arguments[0]).\(arguments[1])") - return 0 - })) - db.add(function: DatabaseFunction.init("didDelete", function: { arguments in - logger.info("didDelete: \(arguments[0]).\(arguments[1])") - return 0 - })) + @Dependency(\.cloudKitDatabase) var cloudKitDatabase + + db.add( + function: DatabaseFunction.init( + "didInsert", + argumentCount: 2, + function: { arguments in + logger.info("didInsert: \(arguments[0]).\(arguments[1])") + guard + let tableName = String.fromDatabaseValue(arguments[0]), + let id = String.fromDatabaseValue(arguments[1]) + else { + return 0 + } + cloudKitDatabase.didInsert(tableName: tableName, id: id) + return 0 + } + ) + ) + db.add( + function: DatabaseFunction.init( + "didUpdate", + argumentCount: 2, + function: { arguments in + logger.info("didUpdate: \(arguments[0]).\(arguments[1])") + guard + let tableName = String.fromDatabaseValue(arguments[0]), + let id = String.fromDatabaseValue(arguments[1]) + else { + return 0 + } + cloudKitDatabase.didUpdate(tableName: tableName, id: id) + return 0 + } + ) + ) + db.add( + function: DatabaseFunction.init( + "willDelete", + argumentCount: 2, + function: { arguments in + logger.info("willDelete: \(arguments[0]).\(arguments[1])") + guard + let tableName = String.fromDatabaseValue(arguments[0]), + let id = String.fromDatabaseValue(arguments[1]) + else { + return 0 + } + cloudKitDatabase.willDelete(tableName: tableName, id: id) + return 0 + } + ) + ) + let tableNames = try #sql( """ - SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' + SELECT "name" FROM "sqlite_master" + WHERE "type" = 'table' + AND "name" NOT LIKE 'sqlite_%' + AND "name" NOT LIKE 'grdb_%' """, as: String.self ) .fetchAll(db) - .filter { !$0.hasPrefix("sqlite_") && !$0.hasPrefix("grdb_") } + cloudKitDatabase.saveZones(tableNames: tableNames) for tableName in tableNames { - try #sql( - """ - DROP TRIGGER IF EXISTS "__\(raw: tableName)_sync_inserts" - """ - ) - .execute(db) - try #sql( - """ - DROP TRIGGER IF EXISTS "__\(raw: tableName)_sync_updates" - """ - ) - .execute(db) - try #sql( - """ - DROP TRIGGER IF EXISTS "__\(raw: tableName)_sync_deletes" - """ - ) - .execute(db) -// // TODO: what about tables without 'id'? - try #sql( - """ - CREATE TRIGGER "__\(raw: tableName)_sync_inserts" - AFTER INSERT ON "\(raw: tableName)" FOR EACH ROW BEGIN - SELECT didInsert('\(raw: tableName)', new.rowid); - END - """ - ) - .execute(db) - try #sql( - """ - CREATE TRIGGER "__\(raw: tableName)_sync_updates" - AFTER UPDATE ON "\(raw: tableName)" FOR EACH ROW BEGIN - SELECT didUpdate('\(raw: tableName)', new.rowid); - END - """ - ) - .execute(db) - try #sql( + try Trigger.delete(tableName: tableName).sql + .execute(db) + try Trigger.insert(tableName: tableName).sql + .execute(db) + try Trigger.update(tableName: tableName).sql + .execute(db) + } +} + +struct Trigger { + let idColumn: String + let function: String + let tableName: String + let type: String + let when: String + static func delete(tableName: String) -> Self { + Trigger(idColumn: "old.id", function: "willDelete", tableName: tableName, type: "DELETE", when: "BEFORE") + } + static func insert(tableName: String) -> Self { + Trigger(idColumn: "new.id", function: "didInsert", tableName: tableName, type: "INSERT", when: "AFTER") + } + static func update(tableName: String) -> Self { + Trigger(idColumn: "new.id", function: "didUpdate", tableName: tableName, type: "UPDATE", when: "AFTER") + } + var sql: SQLQueryExpression { + #sql( """ - CREATE TRIGGER "__\(raw: tableName)_sync_deletes" - BEFORE DELETE ON "\(raw: tableName)" FOR EACH ROW BEGIN - SELECT didDelete('\(raw: tableName)', old.rowid); + CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" + \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN + SELECT \(raw: function)('\(raw: tableName)', \(raw: idColumn)); END """ ) - .execute(db) } } -// -//func insertTrigger(tableName: String, id: UUID) { -// @Dependency(\.cloudKitDatabase) var db -// db.add(pendingRecordZoneChanges: .saveRecord(CKRecord.ID(zoneID: tableName, recordName: id))) -//} -//func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? { -//} -// - -private let logger = Logger(subsystem: "Reminders", category: "Database") +let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { @@ -450,15 +462,15 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Tag(id: UUID(5), title: "social") Tag(id: UUID(6), title: "night") Tag(id: UUID(7), title: "adulting") - - ReminderTag(reminderID: UUID(1), tagID: UUID(3)) - ReminderTag(reminderID: UUID(1), tagID: UUID(4)) - ReminderTag(reminderID: UUID(1), tagID: UUID(7)) - ReminderTag(reminderID: UUID(2), tagID: UUID(3)) - ReminderTag(reminderID: UUID(2), tagID: UUID(4)) - ReminderTag(reminderID: UUID(3), tagID: UUID(7)) - ReminderTag(reminderID: UUID(4), tagID: UUID(1)) - ReminderTag(reminderID: UUID(4), tagID: UUID(2)) + + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(3)) + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(4)) + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(7)) + ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(3)) + ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(4)) + ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(7)) + ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(1)) + ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(2)) } } } From d91bd69d8a7fe362ec0b131878633773f4e239dc Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 8 May 2025 21:08:00 -0500 Subject: [PATCH 003/581] wip --- Examples/Examples.xcodeproj/project.pbxproj | 3 + Examples/Reminders/CloudKitDatabase.swift | 416 +++++++++++++++++--- Examples/Reminders/Info.plist | 10 + Examples/Reminders/RemindersLists.swift | 18 +- Examples/Reminders/Schema.swift | 2 +- 5 files changed, 396 insertions(+), 53 deletions(-) create mode 100644 Examples/Reminders/Info.plist diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 465f5650..35304b15 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ DCA44CFA2D5D9D1E008D4E76 /* Exceptions for "Reminders" folder in "Reminders" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Info.plist, README.md, ); target = CAF836D72D4735AB0047AEB5 /* Reminders */; @@ -717,6 +718,7 @@ DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -746,6 +748,7 @@ DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Examples/Reminders/CloudKitDatabase.swift b/Examples/Reminders/CloudKitDatabase.swift index 32be227b..d222ec9b 100644 --- a/Examples/Reminders/CloudKitDatabase.swift +++ b/Examples/Reminders/CloudKitDatabase.swift @@ -26,23 +26,33 @@ class CloudKitDatabase: @unchecked Sendable { let container: CKContainer let syncEngine: CKSyncEngine var stateSerialization: CKSyncEngine.State.Serialization? - let delegate = Delegate() + let delegate: Delegate init(container: CKContainer) { self.container = container - - var configuration = CKSyncEngine.Configuration( + self.delegate = Delegate(container: container) + let stateSerializationData = + UserDefaults.standard.data( + forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) + ) ?? Data() + stateSerialization = try? JSONDecoder() + .decode( + CKSyncEngine.State.Serialization.self, + from: stateSerializationData + ) + let configuration = CKSyncEngine.Configuration( database: container.privateCloudDatabase, stateSerialization: stateSerialization, delegate: delegate ) - configuration.automaticallySync = true syncEngine = CKSyncEngine(configuration) } func saveZones(tableNames: [String]) { syncEngine.state.add( - pendingDatabaseChanges: tableNames.map { .saveZone(CKRecordZone(zoneName: $0)) } + pendingDatabaseChanges: tableNames.map { + .saveZone(CKRecordZone(zoneName: $0)) + } ) } @@ -84,41 +94,40 @@ class CloudKitDatabase: @unchecked Sendable { ) ] ) - -// let contacts = ids.compactMap { self.appData.contacts[$0] } -// for id in ids { -// self.appData.contacts[id] = nil -// } -// try self.persistLocalData() -// -// let pendingDeletions: [CKSyncEngine.PendingRecordZoneChange] = contacts.map { .deleteRecord($0.recordID) } -// self.syncEngine.state.add(pendingRecordZoneChanges: pendingDeletions) - } } -final class Delegate: CKSyncEngineDelegate { +final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { + @Dependency(\.defaultDatabase) var database + let container: CKContainer + init(container: CKContainer) { + self.container = container + } func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { logger.info("CloudKitDatabase.Delegate.handleEvent.\(event)") switch event { - case .stateUpdate(_): + case .stateUpdate(let stateUpdate): + UserDefaults.standard.set( + try? JSONEncoder().encode(stateUpdate.stateSerialization), + forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) + ) // TODO break case .accountChange(_): // TODO break - case .fetchedDatabaseChanges(_): - // TODO + case .fetchedDatabaseChanges(let changes): + handleFetchedDatabaseChanges(changes) break - case .fetchedRecordZoneChanges(_): - // TODO + case .fetchedRecordZoneChanges(let changes): + handleFetchedRecordZoneChanges(changes) break case .sentDatabaseChanges(_): // TODO break - case .sentRecordZoneChanges(_): - // TODO + case .sentRecordZoneChanges(let changes): + handleSentRecordZoneChanges(changes) break case .willFetchChanges(_): // TODO @@ -144,6 +153,241 @@ final class Delegate: CKSyncEngineDelegate { } } + private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) { + + for savedRecord in changes.savedRecords { + // TODO: do this + } + + for failedRecordSave in changes.failedRecordSaves { + // TODO: do this + // switch failedRecordSave.error.code { + // case .internalError: + // <#code#> + // case .partialFailure: + // <#code#> + // case .networkUnavailable: + // <#code#> + // case .networkFailure: + // <#code#> + // case .badContainer: + // <#code#> + // case .serviceUnavailable: + // <#code#> + // case .requestRateLimited: + // <#code#> + // case .missingEntitlement: + // <#code#> + // case .notAuthenticated: + // <#code#> + // case .permissionFailure: + // <#code#> + // case .unknownItem: + // <#code#> + // case .invalidArguments: + // <#code#> + // case .resultsTruncated: + // <#code#> + // case .serverRecordChanged: + // <#code#> + // case .serverRejectedRequest: + // <#code#> + // case .assetFileNotFound: + // <#code#> + // case .assetFileModified: + // <#code#> + // case .incompatibleVersion: + // <#code#> + // case .constraintViolation: + // <#code#> + // case .operationCancelled: + // <#code#> + // case .changeTokenExpired: + // <#code#> + // case .batchRequestFailed: + // <#code#> + // case .zoneBusy: + // <#code#> + // case .badDatabase: + // <#code#> + // case .quotaExceeded: + // <#code#> + // case .zoneNotFound: + // <#code#> + // case .limitExceeded: + // <#code#> + // case .userDeletedZone: + // <#code#> + // case .tooManyParticipants: + // <#code#> + // case .alreadyShared: + // <#code#> + // case .referenceViolation: + // <#code#> + // case .managedAccountRestricted: + // <#code#> + // case .participantMayNeedVerification: + // <#code#> + // case .serverResponseLost: + // <#code#> + // case .assetNotAvailable: + // <#code#> + // case .accountTemporarilyUnavailable: + // <#code#> + // @unknown default: + // <#fatalError()#> + // } + } + + for failedRecordDelete in changes.failedRecordDeletes { + // TODO: do this + } + + withErrorReporting { + // TODO: double check this is correct. the sample code doesn't have this + try database.write { db in + for deletedRecordID in changes.deletedRecordIDs { + try deletedRecordID.delete(db: db) + } + } + } + + // // If we failed to save a record, we might want to retry depending on the error code. + // var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() + // var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() + // + // // Update the last known server record for each of the saved records. + // for savedRecord in event.savedRecords { + // + // let id = savedRecord.recordID.recordName + // if var contact = self.appData.contacts[id] { + // contact.setLastKnownRecordIfNewer(savedRecord) + // self.appData.contacts[id] = contact + // } + // } + // + // // Handle any failed record saves. + // for failedRecordSave in event.failedRecordSaves { + // let failedRecord = failedRecordSave.record + // let contactID = failedRecord.recordID.recordName + // var shouldClearServerRecord = false + // + // switch failedRecordSave.error.code { + // + // case .serverRecordChanged: + // // Let's merge the record from the server into our own local copy. + // // The `mergeFromServerRecord` function takes care of the conflict resolution. + // guard let serverRecord = failedRecordSave.error.serverRecord else { + // Logger.database.error("No server record for conflict \(failedRecordSave.error)") + // continue + // } + // guard var contact = self.appData.contacts[contactID] else { + // Logger.database.error("No local object for conflict \(failedRecordSave.error)") + // continue + // } + // contact.mergeFromServerRecord(serverRecord) + // contact.setLastKnownRecordIfNewer(serverRecord) + // self.appData.contacts[contactID] = contact + // newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + // + // case .zoneNotFound: + // // Looks like we tried to save a record in a zone that doesn't exist. + // // Let's save that zone and retry saving the record. + // // Also clear the last known server record if we have one, it's no longer valid. + // let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) + // newPendingDatabaseChanges.append(.saveZone(zone)) + // newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + // shouldClearServerRecord = true + // + // case .unknownItem: + // // We tried to save a record with a locally-cached server record, but that record no longer exists on the server. + // // This might mean that another device deleted the record, but we still have the data for that record locally. + // // We have the choice of either deleting the local data or re-uploading the local data. + // // For this sample app, let's re-upload the local data. + // newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + // shouldClearServerRecord = true + // + // case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, + // .operationCancelled: + // // There are several errors that the sync engine will automatically retry, let's just log and move on. + // Logger.database.debug( + // "Retryable error saving \(failedRecord.recordID): \(failedRecordSave.error)" + // ) + // + // default: + // // We got an error, but we don't know what it is or how to handle it. + // // If you have any sort of telemetry system, you should consider tracking this scenario so you can understand which errors you see in the wild. + // Logger.database.fault( + // "Unknown error saving record \(failedRecord.recordID): \(failedRecordSave.error)" + // ) + // } + // + // if shouldClearServerRecord { + // if var contact = self.appData.contacts[contactID] { + // contact.lastKnownRecord = nil + // self.appData.contacts[contactID] = contact + // } + // } + // } + // + // self.syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + // self.syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + // + // // Now that we've processed the batch, save to disk. + // try? self.persistLocalData() + } + + private func handleFetchedRecordZoneChanges( + _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges + ) { + withErrorReporting { + try database.write { db in + for modification in changes.modifications { + let row = try Row.fetchOne( + db, + sql: """ + SELECT * FROM "\(modification.record.recordID.tableName)" + WHERE "id" = ? + """, + arguments: [modification.record.recordID.primaryKey] + ) + if let row { + print(row) + print("?!?!?") + // TODO: fetch CKRecord data from centralized table associated with modification.recordID + // TODO: merge modification.record into saved CKRecord, respecting modification dates + // TODO: merge updated CKRecord state into row data + // TODO: save freshes CKRecord data into centralized database + try modification.record.upsert(db: db) + } else { + try modification.record.upsert(db: db) + // TODO: create entry in centralized database with CKRecord + } + } + + for deletion in changes.deletions { + try deletion.recordID.delete(db: db) + } + } + } + } + + private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) { + withErrorReporting { + try database.write { db in + for deletion in changes.deletions { + let tableName = deletion.zoneID.zoneName + try #sql( + """ + DELETE FROM "\(raw: tableName)" + """ + ) + .execute(db) + } + } + } + } + func nextRecordZoneChangeBatch( _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine @@ -152,47 +396,53 @@ final class Delegate: CKSyncEngineDelegate { let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in - let primaryKey = recordID.recordName - let tableName = recordID.zoneID.zoneName - // TODO: fetch record data from centralized table - let record = CKRecord(recordType: tableName, recordID: recordID) + let record = CKRecord(recordType: recordID.tableName, recordID: recordID) - @Dependency(\.defaultDatabase) var database let row = withErrorReporting { try database.read { db in try Row.fetchOne( db, SQLRequest( sql: """ - SELECT * FROM "\(tableName)" WHERE "id" = ? + SELECT * FROM "\(recordID.tableName)" WHERE "id" = ? """, - arguments: [primaryKey] + arguments: [recordID.primaryKey] ) ) } } guard - let row, // No error was thrown from fetchOne - let row // fetchOne returned a value + let row, // NB: No error was thrown from fetchOne + let row // NB: fetchOne returned a value else { - syncEngine.state.remove(pendingRecordZoneChanges: [ .saveRecord(recordID) ]) + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) return nil } for columnName in row.columnNames { switch row[columnName]?.databaseValue.storage { case .null: - record.encryptedValues[columnName] = nil + if record.encryptedValues[columnName] != nil { + record.encryptedValues[columnName] = nil + } case .int64(let value): - record.encryptedValues[columnName] = value + if record.object(forKey: columnName) as? Int64 != value { + record.encryptedValues[columnName] = value + } case .double(let value): - record.encryptedValues[columnName] = value + if record.object(forKey: columnName) as? Double != value { + record.encryptedValues[columnName] = value + } case .string(let value): - record.encryptedValues[columnName] = value + if record.object(forKey: columnName) as? String != value { + record.encryptedValues[columnName] = value + } case .blob(let value): - record.encryptedValues[columnName] = value + if record.object(forKey: columnName) as? Data != value { + record.encryptedValues[columnName] = value + } case .none: break } @@ -203,11 +453,89 @@ final class Delegate: CKSyncEngineDelegate { } return batch } +} + +extension CKRecord.ID { + fileprivate var primaryKey: String { recordName } + fileprivate var tableName: String { zoneID.zoneName } +} - // func nextFetchChangesOptions( - // _ context: CKSyncEngine.FetchChangesContext, - // syncEngine: CKSyncEngine - // ) async -> CKSyncEngine.FetchChangesOptions { - // - // } +private func stateSerializationKey(containerIdentifier: String?) -> String { + (containerIdentifier ?? "") + ".stateSerializationData" +} + +extension CKRecord { + func upsert(db: Database) throws { + let columnNames = try String.fetchAll( + db, + sql: """ + SELECT "name" + FROM pragma_table_info('\(recordID.tableName)') + """ + ) + var query: QueryFragment = """ + INSERT INTO \(raw: recordID.tableName) ( + """ + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) + query.append(""" + ) VALUES ( + """) + query.append( + columnNames.map { columnName in + "\(bind: convert(encryptedValues[columnName]))" + }.joined(separator: ",") + ) + query.append( + """ + ) ON CONFLICT("id") DO UPDATE SET + """ + ) + query.append( + columnNames + .map { " \(quote: $0) = excluded.\(quote: $0)" } + .joined(separator: ",") + ) + try SQLQueryExpression(query).execute(db) + print("?!?!") + } +} + +extension CKRecord.ID { + func delete(db: Database) throws { + try #sql( + """ + DELETE FROM "\(raw: tableName)" + WHERE "id" = \(bind: primaryKey) + """ + ) + .execute(db) + } +} + +extension CKRecordZone.ID { + func deleteAll(db: Database) throws { + try #sql( + """ + DELETE FROM "\(raw: zoneName)" + """ + ) + .execute(db) + } +} + +private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression { + guard let value else { + return _Null(nilLiteral: ()) + } + if let value = value as? Int64 { + return value + } else if let value = value as? Double { + return value + } else if let value = value as? String { + return value + } else if let value = value as? Data { + return value + } else { + fatalError("TODO: do we need to do all numeric types?") + } } diff --git a/Examples/Reminders/Info.plist b/Examples/Reminders/Info.plist new file mode 100644 index 00000000..ca9a074a --- /dev/null +++ b/Examples/Reminders/Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + remote-notification + + + diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index ccd3de58..5aad93fb 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -25,14 +25,16 @@ struct RemindersListsView: View { private var tags @FetchOne( - Reminder.select { - Stats.Columns( - allCount: $0.count(filter: !$0.isCompleted), - flaggedCount: $0.count(filter: $0.isFlagged), - scheduledCount: $0.count(filter: $0.isScheduled), - todayCount: $0.count(filter: $0.isToday) - ) - } + Reminder + .join(RemindersList.all) { $0.remindersListID.eq($1.id) } + .select { reminder, _ in + Stats.Columns( + allCount: reminder.count(filter: !reminder.isCompleted), + flaggedCount: reminder.count(filter: reminder.isFlagged), + scheduledCount: reminder.count(filter: reminder.isScheduled), + todayCount: reminder.count(filter: reminder.isToday) + ) + } ) private var stats = Stats() diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index afd22fdc..8f705269 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -98,7 +98,7 @@ func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() - //configuration.foreignKeysEnabled = true + configuration.foreignKeysEnabled = false configuration.prepareDatabase { db in #if DEBUG db.trace(options: .profile) { From cc4955a450c1a696ee070f212571933397300e88 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 8 May 2025 21:23:56 -0500 Subject: [PATCH 004/581] wip --- Examples/Reminders/CloudKitDatabase.swift | 196 +++++++++++----------- Examples/Reminders/Schema.swift | 9 +- 2 files changed, 104 insertions(+), 101 deletions(-) diff --git a/Examples/Reminders/CloudKitDatabase.swift b/Examples/Reminders/CloudKitDatabase.swift index d222ec9b..5eb85bdf 100644 --- a/Examples/Reminders/CloudKitDatabase.swift +++ b/Examples/Reminders/CloudKitDatabase.swift @@ -154,93 +154,101 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { } private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) { + withErrorReporting { + try database.write { db in - for savedRecord in changes.savedRecords { - // TODO: do this - } + for savedRecord in changes.savedRecords { + // TODO: do this + } - for failedRecordSave in changes.failedRecordSaves { - // TODO: do this - // switch failedRecordSave.error.code { - // case .internalError: - // <#code#> - // case .partialFailure: - // <#code#> - // case .networkUnavailable: - // <#code#> - // case .networkFailure: - // <#code#> - // case .badContainer: - // <#code#> - // case .serviceUnavailable: - // <#code#> - // case .requestRateLimited: - // <#code#> - // case .missingEntitlement: - // <#code#> - // case .notAuthenticated: - // <#code#> - // case .permissionFailure: - // <#code#> - // case .unknownItem: - // <#code#> - // case .invalidArguments: - // <#code#> - // case .resultsTruncated: - // <#code#> - // case .serverRecordChanged: - // <#code#> - // case .serverRejectedRequest: - // <#code#> - // case .assetFileNotFound: - // <#code#> - // case .assetFileModified: - // <#code#> - // case .incompatibleVersion: - // <#code#> - // case .constraintViolation: - // <#code#> - // case .operationCancelled: - // <#code#> - // case .changeTokenExpired: - // <#code#> - // case .batchRequestFailed: - // <#code#> - // case .zoneBusy: - // <#code#> - // case .badDatabase: - // <#code#> - // case .quotaExceeded: - // <#code#> - // case .zoneNotFound: - // <#code#> - // case .limitExceeded: - // <#code#> - // case .userDeletedZone: - // <#code#> - // case .tooManyParticipants: - // <#code#> - // case .alreadyShared: - // <#code#> - // case .referenceViolation: - // <#code#> - // case .managedAccountRestricted: - // <#code#> - // case .participantMayNeedVerification: - // <#code#> - // case .serverResponseLost: - // <#code#> - // case .assetNotAvailable: - // <#code#> - // case .accountTemporarilyUnavailable: - // <#code#> - // @unknown default: - // <#fatalError()#> - // } + for failedRecordSave in changes.failedRecordSaves { + // TODO: do this + switch failedRecordSave.error.code { + // case .internalError: + // <#code#> + // case .partialFailure: + // <#code#> + // case .networkUnavailable: + // <#code#> + // case .networkFailure: + // <#code#> + // case .badContainer: + // <#code#> + // case .serviceUnavailable: + // <#code#> + // case .requestRateLimited: + // <#code#> + // case .missingEntitlement: + // <#code#> + // case .notAuthenticated: + // <#code#> + // case .permissionFailure: + // <#code#> + // case .unknownItem: + // <#code#> + // case .invalidArguments: + // <#code#> + // case .resultsTruncated: + // <#code#> + case .serverRecordChanged: + try failedRecordSave.error.serverRecord?.upsert(db: db) + //newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + print("?!?!") + // case .serverRejectedRequest: + // <#code#> + // case .assetFileNotFound: + // <#code#> + // case .assetFileModified: + // <#code#> + // case .incompatibleVersion: + // <#code#> + // case .constraintViolation: + // <#code#> + // case .operationCancelled: + // <#code#> + // case .changeTokenExpired: + // <#code#> + // case .batchRequestFailed: + // <#code#> + // case .zoneBusy: + // <#code#> + // case .badDatabase: + // <#code#> + // case .quotaExceeded: + // <#code#> + // case .zoneNotFound: + // <#code#> + // case .limitExceeded: + // <#code#> + // case .userDeletedZone: + // <#code#> + // case .tooManyParticipants: + // <#code#> + // case .alreadyShared: + // <#code#> + // case .referenceViolation: + // <#code#> + // case .managedAccountRestricted: + // <#code#> + // case .participantMayNeedVerification: + // <#code#> + // case .serverResponseLost: + // <#code#> + // case .assetNotAvailable: + // <#code#> + // case .accountTemporarilyUnavailable: + // <#code#> + + default: + fatalError() + } + } + } } - for failedRecordDelete in changes.failedRecordDeletes { + for (recordID, failedRecordDelete) in changes.failedRecordDeletes { // TODO: do this + print(failedRecordDelete) } withErrorReporting { @@ -343,17 +351,15 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { withErrorReporting { try database.write { db in for modification in changes.modifications { - let row = try Row.fetchOne( - db, - sql: """ - SELECT * FROM "\(modification.record.recordID.tableName)" - WHERE "id" = ? - """, - arguments: [modification.record.recordID.primaryKey] + let count = try #sql( + """ + SELECT count(*) FROM "\(raw: modification.record.recordID.tableName)" + WHERE "id" = \(bind: modification.record.recordID.primaryKey) + """, + as: Int.self ) - if let row { - print(row) - print("?!?!?") + .fetchOne(db) ?? 0 + if count > 0 { // TODO: fetch CKRecord data from centralized table associated with modification.recordID // TODO: merge modification.record into saved CKRecord, respecting modification dates // TODO: merge updated CKRecord state into row data @@ -477,9 +483,11 @@ extension CKRecord { INSERT INTO \(raw: recordID.tableName) ( """ query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) - query.append(""" + query.append( + """ ) VALUES ( - """) + """ + ) query.append( columnNames.map { columnName in "\(bind: convert(encryptedValues[columnName]))" diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 8f705269..063eab73 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -142,9 +142,7 @@ func appDatabase() throws -> any DatabaseWriter { "notes" TEXT, "priority" INTEGER, "remindersListID" TEXT NOT NULL, - "title" TEXT NOT NULL, - - FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE + "title" TEXT NOT NULL ) STRICT """ ) @@ -163,10 +161,7 @@ func appDatabase() throws -> any DatabaseWriter { CREATE TABLE "remindersTags" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "reminderID" TEXT NOT NULL, - "tagID" TEXT NOT NULL, - - FOREIGN KEY("reminderID") REFERENCES "reminders"("id") ON DELETE CASCADE, - FOREIGN KEY("tagID") REFERENCES "tags"("id") ON DELETE CASCADE + "tagID" TEXT NOT NULL ) STRICT """ ) From 65a6fd3d743623ffe05af5814ecf547a61837806 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 May 2025 13:07:13 -0500 Subject: [PATCH 005/581] wip --- Examples/Reminders/CloudKitDatabase.swift | 643 +++++++++++++++------ Examples/Reminders/ReminderRow.swift | 48 +- Examples/Reminders/RemindersListForm.swift | 4 +- Examples/Reminders/RemindersLists.swift | 23 +- Examples/Reminders/Schema.swift | 343 ++++------- 5 files changed, 635 insertions(+), 426 deletions(-) diff --git a/Examples/Reminders/CloudKitDatabase.swift b/Examples/Reminders/CloudKitDatabase.swift index 5eb85bdf..20d927cb 100644 --- a/Examples/Reminders/CloudKitDatabase.swift +++ b/Examples/Reminders/CloudKitDatabase.swift @@ -23,6 +23,8 @@ extension CloudKitDatabase: TestDependencyKey { // TODO: fix sendable by either making actor or locking mutable state class CloudKitDatabase: @unchecked Sendable { + @Dependency(\.defaultDatabase) var database + let container: CKContainer let syncEngine: CKSyncEngine var stateSerialization: CKSyncEngine.State.Serialization? @@ -35,17 +37,20 @@ class CloudKitDatabase: @unchecked Sendable { UserDefaults.standard.data( forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) ) ?? Data() - stateSerialization = try? JSONDecoder() - .decode( - CKSyncEngine.State.Serialization.self, - from: stateSerializationData - ) + stateSerialization = withErrorReporting { + try JSONDecoder() + .decode( + CKSyncEngine.State.Serialization.self, + from: stateSerializationData + ) + } let configuration = CKSyncEngine.Configuration( database: container.privateCloudDatabase, stateSerialization: stateSerialization, delegate: delegate ) syncEngine = CKSyncEngine(configuration) + delegate.syncEngine = syncEngine } func saveZones(tableNames: [String]) { @@ -95,11 +100,23 @@ class CloudKitDatabase: @unchecked Sendable { ] ) } + + #if DEBUG + func deleteAllRecords() async throws { + let tableNames = try await database.read { try $0.tableNames } + for tableName in tableNames { + let zoneID = CKRecordZone.ID(zoneName: tableName) + syncEngine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) + try await syncEngine.sendChanges() + } + } + #endif } final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { @Dependency(\.defaultDatabase) var database let container: CKContainer + var syncEngine: CKSyncEngine! init(container: CKContainer) { self.container = container } @@ -108,11 +125,12 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { logger.info("CloudKitDatabase.Delegate.handleEvent.\(event)") switch event { case .stateUpdate(let stateUpdate): - UserDefaults.standard.set( - try? JSONEncoder().encode(stateUpdate.stateSerialization), - forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) - ) - // TODO + withErrorReporting { + UserDefaults.standard.set( + try JSONEncoder().encode(stateUpdate.stateSerialization), + forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) + ) + } break case .accountChange(_): // TODO @@ -154,93 +172,114 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { } private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) { + var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() + var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() + defer { + syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + } + withErrorReporting { try database.write { db in - for savedRecord in changes.savedRecords { - // TODO: do this + try db.cacheNewRecordIfNewer(savedRecord) } for failedRecordSave in changes.failedRecordSaves { // TODO: do this - switch failedRecordSave.error.code { - // case .internalError: - // <#code#> - // case .partialFailure: - // <#code#> - // case .networkUnavailable: - // <#code#> - // case .networkFailure: - // <#code#> - // case .badContainer: - // <#code#> - // case .serviceUnavailable: - // <#code#> - // case .requestRateLimited: - // <#code#> - // case .missingEntitlement: - // <#code#> - // case .notAuthenticated: - // <#code#> - // case .permissionFailure: - // <#code#> - // case .unknownItem: - // <#code#> - // case .invalidArguments: - // <#code#> - // case .resultsTruncated: - // <#code#> + switch failedRecordSave.error.code { + // case .internalError: + // <#code#> + // case .partialFailure: + // <#code#> + // case .networkUnavailable: + // <#code#> + // case .networkFailure: + // <#code#> + // case .badContainer: + // <#code#> + // case .serviceUnavailable: + // <#code#> + // case .requestRateLimited: + // <#code#> + // case .missingEntitlement: + // <#code#> + // case .notAuthenticated: + // <#code#> + // case .permissionFailure: + // <#code#> + case .unknownItem: + print("") + // case .invalidArguments: + // <#code#> + // case .resultsTruncated: + // <#code#> case .serverRecordChanged: - try failedRecordSave.error.serverRecord?.upsert(db: db) - //newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - print("?!?!") - // case .serverRejectedRequest: - // <#code#> - // case .assetFileNotFound: - // <#code#> - // case .assetFileModified: - // <#code#> - // case .incompatibleVersion: - // <#code#> - // case .constraintViolation: - // <#code#> - // case .operationCancelled: - // <#code#> - // case .changeTokenExpired: - // <#code#> - // case .batchRequestFailed: - // <#code#> - // case .zoneBusy: - // <#code#> - // case .badDatabase: - // <#code#> - // case .quotaExceeded: - // <#code#> - // case .zoneNotFound: - // <#code#> - // case .limitExceeded: - // <#code#> - // case .userDeletedZone: - // <#code#> - // case .tooManyParticipants: - // <#code#> - // case .alreadyShared: - // <#code#> - // case .referenceViolation: - // <#code#> - // case .managedAccountRestricted: - // <#code#> - // case .participantMayNeedVerification: - // <#code#> - // case .serverResponseLost: - // <#code#> - // case .assetNotAvailable: - // <#code#> - // case .accountTemporarilyUnavailable: - // <#code#> + guard let serverRecord = failedRecordSave.error.serverRecord + else { continue } + try db.cacheNewRecordIfNewer(serverRecord) + try serverRecord.upsertIfNewer(db: db) + print(serverRecord.recordID, failedRecordSave.record.recordID, serverRecord.recordID == failedRecordSave.record.recordID) + newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) + // case .serverRejectedRequest: + // <#code#> + // case .assetFileNotFound: + // <#code#> + // case .assetFileModified: + // <#code#> + // case .incompatibleVersion: + // <#code#> + // case .constraintViolation: + // <#code#> + // case .operationCancelled: + // <#code#> + // case .changeTokenExpired: + // <#code#> + // case .batchRequestFailed: + // <#code#> + // case .zoneBusy: + // <#code#> + // case .badDatabase: + // <#code#> + // case .quotaExceeded: + // <#code#> + case .zoneNotFound: + // TODO: recreate zone if it matches a table name? + let zone = CKRecordZone(zoneID: failedRecordSave.record.recordID.zoneID) + newPendingDatabaseChanges.append(.saveZone(zone)) + newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) + + // case .limitExceeded: + // <#code#> + // case .userDeletedZone: + // <#code#> + // case .tooManyParticipants: + // <#code#> + // case .alreadyShared: + // <#code#> + // case .referenceViolation: + // <#code#> + // case .managedAccountRestricted: + // <#code#> + // case .participantMayNeedVerification: + // <#code#> + // case .serverResponseLost: + // <#code#> + // case .assetNotAvailable: + // <#code#> + // case .accountTemporarilyUnavailable: + // <#code#> + + case .networkFailure, + .networkUnavailable, + .zoneBusy, + .serviceUnavailable, + .notAuthenticated, + .operationCancelled: + print("") default: - fatalError() + reportIssue("Unhandled error: \(failedRecordSave.error.code)") } } } @@ -351,24 +390,8 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { withErrorReporting { try database.write { db in for modification in changes.modifications { - let count = try #sql( - """ - SELECT count(*) FROM "\(raw: modification.record.recordID.tableName)" - WHERE "id" = \(bind: modification.record.recordID.primaryKey) - """, - as: Int.self - ) - .fetchOne(db) ?? 0 - if count > 0 { - // TODO: fetch CKRecord data from centralized table associated with modification.recordID - // TODO: merge modification.record into saved CKRecord, respecting modification dates - // TODO: merge updated CKRecord state into row data - // TODO: save freshes CKRecord data into centralized database - try modification.record.upsert(db: db) - } else { - try modification.record.upsert(db: db) - // TODO: create entry in centralized database with CKRecord - } + try modification.record.upsertIfNewer(db: db) + try db.cacheNewRecordIfNewer(modification.record) } for deletion in changes.deletions { @@ -389,6 +412,12 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { """ ) .execute(db) + + syncEngine.state.add( + pendingDatabaseChanges: [ + .saveZone(CKRecordZone(zoneName: tableName)) + ] + ) } } } @@ -402,12 +431,10 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in - // TODO: fetch record data from centralized table - let record = CKRecord(recordType: recordID.tableName, recordID: recordID) - - let row = withErrorReporting { - try database.read { db in - try Row.fetchOne( + do { + return try database.write { db in + let record = try db.fetchLastCachedRecord(id: recordID) + let row = try Row.fetchOne( db, SQLRequest( sql: """ @@ -416,48 +443,53 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { arguments: [recordID.primaryKey] ) ) - } - } - guard - let row, // NB: No error was thrown from fetchOne - let row // NB: fetchOne returned a value - else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + guard let row + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil + } + record.update(with: row) + try db.cacheNewRecordIfNewer(record) + return record + } + } catch { + reportIssue(error) return nil } + } + return batch + } +} - for columnName in row.columnNames { - switch row[columnName]?.databaseValue.storage { - case .null: - if record.encryptedValues[columnName] != nil { - record.encryptedValues[columnName] = nil - } - case .int64(let value): - if record.object(forKey: columnName) as? Int64 != value { - record.encryptedValues[columnName] = value - } - case .double(let value): - if record.object(forKey: columnName) as? Double != value { - record.encryptedValues[columnName] = value - } - case .string(let value): - if record.object(forKey: columnName) as? String != value { - record.encryptedValues[columnName] = value - } - case .blob(let value): - if record.object(forKey: columnName) as? Data != value { - record.encryptedValues[columnName] = value - } - case .none: - break +extension CKRecord { + func update(with row: Row) { + for columnName in row.columnNames { + switch row[columnName]?.databaseValue.storage { + case .null: + if encryptedValues[columnName] != nil { + encryptedValues[columnName] = nil + } + case .int64(let value): + if object(forKey: columnName) as? Int64 != value { + encryptedValues[columnName] = value + } + case .double(let value): + if object(forKey: columnName) as? Double != value { + encryptedValues[columnName] = value } + case .string(let value): + if object(forKey: columnName) as? String != value { + encryptedValues[columnName] = value + } + case .blob(let value): + if object(forKey: columnName) as? Data != value { + encryptedValues[columnName] = value + } + case .none: + break } - // TODO: save new record in centralized table - - return record } - return batch } } @@ -470,41 +502,118 @@ private func stateSerializationKey(containerIdentifier: String?) -> String { (containerIdentifier ?? "") + ".stateSerializationData" } -extension CKRecord { - func upsert(db: Database) throws { - let columnNames = try String.fetchAll( - db, - sql: """ - SELECT "name" - FROM pragma_table_info('\(recordID.tableName)') +extension Database { + func cacheNewRecordIfNewer(_ newRecord: CKRecord) throws { + let existingRecord = try fetchLastCachedRecord(id: newRecord.recordID) + if let existingRecordModificationDate = existingRecord.modificationDate { + if let newRecordModificationDate = newRecord.modificationDate, + existingRecordModificationDate < newRecordModificationDate + { + try update() + } else { + print("Modification date caught") + } + } else { + try update() + } + + func update() throws { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + newRecord.encodeSystemFields(with: archiver) + // TODO: should we use userModificationDate based on record.modificationDate? + try #sql( """ - ) - var query: QueryFragment = """ - INSERT INTO \(raw: recordID.tableName) ( - """ - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) - query.append( - """ - ) VALUES ( - """ - ) - query.append( - columnNames.map { columnName in - "\(bind: convert(encryptedValues[columnName]))" - }.joined(separator: ",") - ) - query.append( - """ - ) ON CONFLICT("id") DO UPDATE SET + INSERT INTO "sharing_grdb_cloudkit" + ("tableName", "primaryKey", "recordData", "userModificationDate") + VALUES ( + \(bind: newRecord.recordID.tableName), + \(bind: newRecord.recordID.primaryKey), + \(archiver.encodedData), + \(bind: Date.ISO8601Representation(queryOutput: newRecord.modificationDate ?? Date())) + ) + ON CONFLICT("tableName", "primaryKey") DO UPDATE SET + "recordData" = \(archiver.encodedData) + """ + ) + .execute(self) + } + } + + func fetchLastCachedRecord(id recordID: CKRecord.ID) throws -> CKRecord { + return try #sql( """ + SELECT "recordData" + FROM "sharing_grdb_cloudkit" + WHERE "tableName" = \(bind: recordID.tableName) + AND "primaryKey" = \(bind: recordID.primaryKey) + """, + as: Data?.self ) - query.append( - columnNames - .map { " \(quote: $0) = excluded.\(quote: $0)" } - .joined(separator: ",") - ) - try SQLQueryExpression(query).execute(db) - print("?!?!") + .fetchOne(self) + .flatMap { $0 } + .flatMap { data in + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = true + return CKRecord(coder: unarchiver) + } + ?? CKRecord(recordType: recordID.tableName, recordID: recordID) + } +} + +extension CKRecord { + func upsertIfNewer(db: Database) throws { + let userModificationDate = + try #sql( + """ + SELECT "userModificationDate" FROM "sharing_grdb_cloudkit" + WHERE "tableName" = \(bind: recordID.tableName) + AND "primaryKey" = \(bind: recordID.primaryKey) + """, + as: Date?.ISO8601Representation.self + ) + .fetchOne(db) + ?? nil + + + if let userModificationDate, + userModificationDate > (modificationDate ?? .distantPast) + { + print("Modification date caught") + } else { + // TODO: can we use record.keysChanged to update only columns that changed? + let columnNames = try String.fetchAll( + db, + sql: """ + SELECT "name" + FROM pragma_table_info('\(recordID.tableName)') + """ + ) + var query: QueryFragment = """ + INSERT INTO "\(raw: recordID.tableName)" ( + """ + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) + query.append( + """ + ) VALUES ( + """ + ) + query.append( + columnNames.map { columnName in + "\(bind: convert(encryptedValues[columnName]))" + }.joined(separator: ",") + ) + query.append( + """ + ) ON CONFLICT("id") DO UPDATE SET + """ + ) + query.append( + columnNames + .map { " \(quote: $0) = excluded.\(quote: $0)" } + .joined(separator: ",") + ) + try SQLQueryExpression(query).execute(db) + } } } @@ -533,7 +642,8 @@ extension CKRecordZone.ID { private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression { guard let value else { - return _Null(nilLiteral: ()) + // TODO: better way? + return SQLQueryExpression("NULL", as: Void?.self) } if let value = value as? Int64 { return value @@ -547,3 +657,178 @@ private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression fatalError("TODO: do we need to do all numeric types?") } } + +extension DatabaseWriter { + func setUp(migrator: DatabaseMigrator, body: (inout DatabaseMigrator) throws -> Void) throws { + var migrator = migrator + try body(&migrator) + migrator.registerMigration("Create SharingGRDB tables") { db in + try #sql( + """ + CREATE TABLE "sharing_grdb_cloudkit" ( + "tableName" TEXT NOT NULL, + "primaryKey" TEXT NOT NULL, + "recordData" BLOB, + "userModificationDate" TEXT, + PRIMARY KEY("tableName", "primaryKey") + ) + """ + ) + .execute(db) + } + try write { db in + try installTriggers(db: db) + } + try migrator.migrate(self) + } +} + +extension Database { + //func cascade(of: String, whenDeleting: String) + func installForeignKeyTrigger( + _ childTable: Child.Type, + belongsTo: Parent.Type, + through foreignKey: TableColumn + ) throws { + try #sql( + """ + CREATE TEMP TRIGGER "foreign_key_\(raw: Child.tableName)_belongsTo_\(raw: Parent.tableName)" + AFTER DELETE ON \(Child.self) + FOR EACH ROW BEGIN + DELETE FROM \(Parent.self) + WHERE \(foreignKey) = old.\(raw: Child.columns.primaryKey.name); + END + """ + ) + .execute(self) + } +} + +extension DatabaseFunction { + convenience init(name: String, function: @escaping @Sendable (String, String) -> Void) { + self.init(name, argumentCount: 2) { arguments in + guard + let tableName = String.fromDatabaseValue(arguments[0]), + let id = String.fromDatabaseValue(arguments[1]) + else { + return 0 + } + function(tableName, id) + return 0 + } + } +} + +func installTriggers(db: Database) throws { + @Dependency(\.cloudKitDatabase) var cloudKitDatabase + db.add( + function: DatabaseFunction( + name: "didInsert", + function: cloudKitDatabase.didInsert(tableName:id:) + ) + ) + db.add( + function: DatabaseFunction( + name: "didUpdate", + function: cloudKitDatabase.didUpdate(tableName:id:) + ) + ) + db.add( + function: DatabaseFunction( + name: "willDelete", + function: cloudKitDatabase.willDelete(tableName:id:) + ) + ) + db.add(function: DatabaseFunction("currentDate", argumentCount: 0, function: { _ in + Date() + })) + let tableNames = try db.tableNames + cloudKitDatabase.saveZones(tableNames: tableNames) + for tableName in tableNames { + try Trigger.delete(tableName: tableName).sql + .execute(db) + try Trigger.insert(tableName: tableName).sql + .execute(db) + try Trigger.update(tableName: tableName).sql + .execute(db) + try #sql(""" + CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: tableName)_userModificationDate" + AFTER UPDATE ON "\(raw: tableName)" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit" + ("tableName", "primaryKey", "userModificationDate") + VALUES + ( + '\(raw: tableName)', + new."id", + currentDate() + ) + ON CONFLICT("tableName", "primaryKey") DO UPDATE SET + "userModificationDate" = excluded."userModificationDate"; + END + """) + .execute(db) + } +} + +extension Database { + var tableNames: [String] { + get throws { + try #sql( + """ + SELECT "name" FROM "sqlite_master" + WHERE "type" = 'table' + AND "name" NOT LIKE 'sqlite_%' + AND "name" NOT LIKE 'grdb_%' + AND "name" NOT LIKE 'sharing_grdb_%' + """, + as: String.self + ) + .fetchAll(self) + } + } +} + +struct Trigger { + let idColumn: String + let function: String + let tableName: String + let type: String + let when: String + static func delete(tableName: String) -> Self { + Trigger( + idColumn: "old.id", + function: "willDelete", + tableName: tableName, + type: "DELETE", + when: "BEFORE" + ) + } + static func insert(tableName: String) -> Self { + Trigger( + idColumn: "new.id", + function: "didInsert", + tableName: tableName, + type: "INSERT", + when: "AFTER" + ) + } + static func update(tableName: String) -> Self { + Trigger( + idColumn: "new.id", + function: "didUpdate", + tableName: tableName, + type: "UPDATE", + when: "AFTER" + ) + } + var sql: SQLQueryExpression { + #sql( + """ + CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" + \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN + SELECT \(raw: function)('\(raw: tableName)', \(raw: idColumn)); + END + """ + ) + } +} diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index ad604b08..54d40df7 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -11,7 +11,7 @@ struct ReminderRow: View { let tags: [String] @State var editReminder: Reminder? - @State var isCompleted: Bool + //@State var isCompleted: Bool @Dependency(\.defaultDatabase) private var database @@ -33,14 +33,14 @@ struct ReminderRow: View { self.showCompleted = showCompleted self.tags = tags self.editReminder = editReminder - self.isCompleted = reminder.isCompleted +// self.isCompleted = reminder.isCompleted } var body: some View { HStack { HStack(alignment: .firstTextBaseline) { Button(action: completeButtonTapped) { - Image(systemName: isCompleted ? "circle.inset.filled" : "circle") + Image(systemName: reminder.isCompleted ? "circle.inset.filled" : "circle") .foregroundStyle(.gray) .font(.title2) .padding([.trailing], 5) @@ -58,7 +58,7 @@ struct ReminderRow: View { } } Spacer() - if !isCompleted { + if !reminder.isCompleted { HStack { if reminder.isFlagged { Image(systemName: "flag.fill") @@ -103,36 +103,36 @@ struct ReminderRow: View { .navigationTitle("Details") } } - .task(id: isCompleted) { - guard !showCompleted else { return } - guard - isCompleted, - isCompleted != reminder.isCompleted - else { return } - do { - try await Task.sleep(for: .seconds(2)) - toggleCompletion() - } catch {} - } +// .task(id: isCompleted) { +// guard !showCompleted else { return } +// guard +// isCompleted, +// isCompleted != reminder.isCompleted +// else { return } +// do { +// try await Task.sleep(for: .seconds(2)) +// toggleCompletion() +// } catch {} +// } } private func completeButtonTapped() { - if showCompleted { +// if showCompleted { toggleCompletion() - } else { - isCompleted.toggle() - } +// } else { +// isCompleted.toggle() +// } } private func toggleCompletion() { withErrorReporting { try database.write { db in - isCompleted = +// isCompleted = try Reminder .find(UUID.LowercasedRepresentation(queryOutput: reminder.id)) .update { $0.isCompleted.toggle() } - .returning(\.isCompleted) - .fetchOne(db) ?? isCompleted +// .returning(\.isCompleted) + .fetchOne(db)// ?? isCompleted } } } @@ -162,10 +162,10 @@ struct ReminderRow: View { return HStack(alignment: .firstTextBaseline) { if let priority = reminder.priority { Text(String(repeating: "!", count: priority.rawValue)) - .foregroundStyle(isCompleted ? .gray : remindersList.color) + .foregroundStyle(reminder.isCompleted ? .gray : remindersList.color) } Text(reminder.title) - .foregroundStyle(isCompleted ? .gray : .primary) + .foregroundStyle(reminder.isCompleted ? .gray : .primary) } .font(.title3) } diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 448491d0..36146091 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -34,7 +34,9 @@ struct RemindersListForm: View { Button("Save") { withErrorReporting { try database.write { db in - try RemindersList.upsert(remindersList) + let query = RemindersList.upsert(remindersList) + print(query.queryFragment.debugDescription) + try query .execute(db) } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 5aad93fb..28741548 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -26,13 +26,12 @@ struct RemindersListsView: View { @FetchOne( Reminder - .join(RemindersList.all) { $0.remindersListID.eq($1.id) } - .select { reminder, _ in + .select { Stats.Columns( - allCount: reminder.count(filter: !reminder.isCompleted), - flaggedCount: reminder.count(filter: reminder.isFlagged), - scheduledCount: reminder.count(filter: reminder.isScheduled), - todayCount: reminder.count(filter: reminder.isToday) + allCount: $0.count(filter: !$0.isCompleted), + flaggedCount: $0.count(filter: $0.isFlagged && !$0.isCompleted), + scheduledCount: $0.count(filter: $0.isScheduled), + todayCount: $0.count(filter: $0.isToday) ) } ) @@ -42,6 +41,7 @@ struct RemindersListsView: View { @State private var remindersDetailType: RemindersDetailView.DetailType? @State private var searchText = "" + @Dependency(\.cloudKitDatabase) var cloudKitDatabase @Dependency(\.defaultDatabase) private var database @Selection @@ -172,6 +172,17 @@ struct RemindersListsView: View { .id(searchText) .listStyle(.insetGrouped) .toolbar { + #if DEBUG + ToolbarItem(placement: .destructiveAction) { + Button("Clear data") { + Task { + await withErrorReporting { + try await cloudKitDatabase.deleteAllRecords() + } + } + } + } + #endif ToolbarItem(placement: .bottomBar) { HStack { Button { diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 063eab73..7601b468 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -1,3 +1,4 @@ +import CloudKit import Foundation import IssueReporting import OSLog @@ -121,233 +122,142 @@ func appDatabase() throws -> any DatabaseWriter { #if DEBUG migrator.eraseDatabaseOnSchemaChange = true #endif - migrator.registerMigration("Create initial tables") { db in - try #sql( - """ - CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), - "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), - "title" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), - "dueDate" TEXT, - "isCompleted" INTEGER NOT NULL DEFAULT 0, - "isFlagged" INTEGER NOT NULL DEFAULT 0, - "notes" TEXT, - "priority" INTEGER, - "remindersListID" TEXT NOT NULL, - "title" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL COLLATE NOCASE UNIQUE - ) STRICT - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "remindersTags" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "reminderID" TEXT NOT NULL, - "tagID" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - } - migrator.registerMigration("Add 'position' column to 'remindersLists'") { db in - try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 - """ - ) - .execute(db) - try #sql( - """ - CREATE TRIGGER "default_position_reminders_lists" - AFTER INSERT ON "remindersLists" - FOR EACH ROW BEGIN - UPDATE "remindersLists" - SET "position" = (SELECT max("position") + 1 FROM "remindersLists") - WHERE "id" = NEW."id"; - END - """ - ) - .execute(db) - } - migrator.registerMigration("Add 'position' column to 'reminders'") { db in - try #sql( - """ - ALTER TABLE "reminders" - ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 - """ - ) - .execute(db) - // Backfill position of reminders based on their completion status and due date. - try #sql( - """ - WITH "reminderPositions" AS ( - SELECT - "reminders"."id", - ROW_NUMBER() OVER (PARTITION BY "remindersListID" ORDER BY id) - 1 AS "position" - FROM "reminders" - ORDER BY NOT "isCompleted", "dueDate" DESC + + try database.setUp(migrator: migrator) { migrator in + migrator.registerMigration("Create initial tables") { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), + "title" TEXT NOT NULL + ) STRICT + """ ) - UPDATE "reminders" - SET "position" = "reminderPositions"."position" - FROM "reminderPositions" - WHERE "reminders"."id" = "reminderPositions"."id" - """ - ) - .execute(db) - try #sql( - """ - CREATE TRIGGER "default_position_reminders" - AFTER INSERT ON "reminders" - FOR EACH ROW BEGIN + .execute(db) + try #sql( + """ + CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "dueDate" TEXT, + "isCompleted" INTEGER NOT NULL DEFAULT 0, + "isFlagged" INTEGER NOT NULL DEFAULT 0, + "notes" TEXT, + "priority" INTEGER, + "remindersListID" TEXT NOT NULL, + "title" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "tags" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "title" TEXT NOT NULL COLLATE NOCASE UNIQUE + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "remindersTags" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "reminderID" TEXT NOT NULL, + "tagID" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + } + migrator.registerMigration("Add 'position' column to 'remindersLists'") { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 + """ + ) + .execute(db) + try #sql( + """ + CREATE TRIGGER "default_position_reminders_lists" + AFTER INSERT ON "remindersLists" + FOR EACH ROW BEGIN + UPDATE "remindersLists" + SET "position" = (SELECT max("position") + 1 FROM "remindersLists") + WHERE "id" = NEW."id"; + END + """ + ) + .execute(db) + } + migrator.registerMigration("Add 'position' column to 'reminders'") { db in + try #sql( + """ + ALTER TABLE "reminders" + ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 + """ + ) + .execute(db) + // Backfill position of reminders based on their completion status and due date. + try #sql( + """ + WITH "reminderPositions" AS ( + SELECT + "reminders"."id", + ROW_NUMBER() OVER (PARTITION BY "remindersListID" ORDER BY id) - 1 AS "position" + FROM "reminders" + ORDER BY NOT "isCompleted", "dueDate" DESC + ) UPDATE "reminders" - SET "position" = (SELECT max("position") + 1 FROM "reminders") - WHERE "id" = NEW."id"; - END - """ - ) - .execute(db) - } - - - - #if DEBUG && targetEnvironment(simulator) - if context != .test { - migrator.registerMigration("Seed sample data") { db in - try db.seedSampleData() - } + SET "position" = "reminderPositions"."position" + FROM "reminderPositions" + WHERE "reminders"."id" = "reminderPositions"."id" + """ + ) + .execute(db) + try #sql( + """ + CREATE TRIGGER "default_position_reminders" + AFTER INSERT ON "reminders" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "position" = (SELECT max("position") + 1 FROM "reminders") + WHERE "id" = NEW."id"; + END + """ + ) + .execute(db) } - #endif - try migrator.migrate(database) - - try database.write { db in - try installTriggers(db: db) - } - return database -} - -// TODO: can cloudKitDatabase be created in here and captured in DatabaseFunctions? does any part of the app need access to it? -func installTriggers(db: Database) throws { - @Dependency(\.cloudKitDatabase) var cloudKitDatabase - - db.add( - function: DatabaseFunction.init( - "didInsert", - argumentCount: 2, - function: { arguments in - logger.info("didInsert: \(arguments[0]).\(arguments[1])") - guard - let tableName = String.fromDatabaseValue(arguments[0]), - let id = String.fromDatabaseValue(arguments[1]) - else { - return 0 + #if DEBUG && targetEnvironment(simulator) + if context != .test { + migrator.registerMigration("Seed sample data") { db in + try db.seedSampleData() } - cloudKitDatabase.didInsert(tableName: tableName, id: id) - return 0 } + #endif + } + + try database.write { db in + try db.installForeignKeyTrigger( + RemindersList.self, + belongsTo: Reminder.self, + through: Reminder.remindersListID ) - ) - db.add( - function: DatabaseFunction.init( - "didUpdate", - argumentCount: 2, - function: { arguments in - logger.info("didUpdate: \(arguments[0]).\(arguments[1])") - guard - let tableName = String.fromDatabaseValue(arguments[0]), - let id = String.fromDatabaseValue(arguments[1]) - else { - return 0 - } - cloudKitDatabase.didUpdate(tableName: tableName, id: id) - return 0 - } + try db.installForeignKeyTrigger( + Tag.self, + belongsTo: ReminderTag.self, + through: ReminderTag.tagID ) - ) - db.add( - function: DatabaseFunction.init( - "willDelete", - argumentCount: 2, - function: { arguments in - logger.info("willDelete: \(arguments[0]).\(arguments[1])") - guard - let tableName = String.fromDatabaseValue(arguments[0]), - let id = String.fromDatabaseValue(arguments[1]) - else { - return 0 - } - cloudKitDatabase.willDelete(tableName: tableName, id: id) - return 0 - } + try db.installForeignKeyTrigger( + Reminder.self, + belongsTo: ReminderTag.self, + through: ReminderTag.reminderID ) - ) - - let tableNames = try #sql( - """ - SELECT "name" FROM "sqlite_master" - WHERE "type" = 'table' - AND "name" NOT LIKE 'sqlite_%' - AND "name" NOT LIKE 'grdb_%' - """, - as: String.self - ) - .fetchAll(db) - - cloudKitDatabase.saveZones(tableNames: tableNames) - for tableName in tableNames { - try Trigger.delete(tableName: tableName).sql - .execute(db) - try Trigger.insert(tableName: tableName).sql - .execute(db) - try Trigger.update(tableName: tableName).sql - .execute(db) } -} -struct Trigger { - let idColumn: String - let function: String - let tableName: String - let type: String - let when: String - static func delete(tableName: String) -> Self { - Trigger(idColumn: "old.id", function: "willDelete", tableName: tableName, type: "DELETE", when: "BEFORE") - } - static func insert(tableName: String) -> Self { - Trigger(idColumn: "new.id", function: "didInsert", tableName: tableName, type: "INSERT", when: "AFTER") - } - static func update(tableName: String) -> Self { - Trigger(idColumn: "new.id", function: "didUpdate", tableName: tableName, type: "UPDATE", when: "AFTER") - } - var sql: SQLQueryExpression { - #sql( - """ - CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" - \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN - SELECT \(raw: function)('\(raw: tableName)', \(raw: idColumn)); - END - """ - ) - } + return database } let logger = Logger(subsystem: "Reminders", category: "Database") @@ -355,6 +265,7 @@ let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { func seedSampleData() throws { + return try seed { RemindersList( id: UUID(1), From 62a9b10574d4290b24d42afa09f464eccefdcf27 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 May 2025 16:14:12 -0500 Subject: [PATCH 006/581] clean up --- Examples/Reminders/CloudKitDatabase.swift | 105 ++-------------------- 1 file changed, 9 insertions(+), 96 deletions(-) diff --git a/Examples/Reminders/CloudKitDatabase.swift b/Examples/Reminders/CloudKitDatabase.swift index 20d927cb..96983b86 100644 --- a/Examples/Reminders/CloudKitDatabase.swift +++ b/Examples/Reminders/CloudKitDatabase.swift @@ -282,106 +282,20 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { reportIssue("Unhandled error: \(failedRecordSave.error.code)") } } - } - } - for (recordID, failedRecordDelete) in changes.failedRecordDeletes { - // TODO: do this - print(failedRecordDelete) - } + for (recordID, failedRecordDelete) in changes.failedRecordDeletes { + // TODO: do this + print(failedRecordDelete) + } - withErrorReporting { - // TODO: double check this is correct. the sample code doesn't have this - try database.write { db in - for deletedRecordID in changes.deletedRecordIDs { - try deletedRecordID.delete(db: db) + // TODO: double check this is correct. the sample code doesn't have this + try database.write { db in + for deletedRecordID in changes.deletedRecordIDs { + try deletedRecordID.delete(db: db) + } } } } - - // // If we failed to save a record, we might want to retry depending on the error code. - // var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() - // var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() - // - // // Update the last known server record for each of the saved records. - // for savedRecord in event.savedRecords { - // - // let id = savedRecord.recordID.recordName - // if var contact = self.appData.contacts[id] { - // contact.setLastKnownRecordIfNewer(savedRecord) - // self.appData.contacts[id] = contact - // } - // } - // - // // Handle any failed record saves. - // for failedRecordSave in event.failedRecordSaves { - // let failedRecord = failedRecordSave.record - // let contactID = failedRecord.recordID.recordName - // var shouldClearServerRecord = false - // - // switch failedRecordSave.error.code { - // - // case .serverRecordChanged: - // // Let's merge the record from the server into our own local copy. - // // The `mergeFromServerRecord` function takes care of the conflict resolution. - // guard let serverRecord = failedRecordSave.error.serverRecord else { - // Logger.database.error("No server record for conflict \(failedRecordSave.error)") - // continue - // } - // guard var contact = self.appData.contacts[contactID] else { - // Logger.database.error("No local object for conflict \(failedRecordSave.error)") - // continue - // } - // contact.mergeFromServerRecord(serverRecord) - // contact.setLastKnownRecordIfNewer(serverRecord) - // self.appData.contacts[contactID] = contact - // newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - // - // case .zoneNotFound: - // // Looks like we tried to save a record in a zone that doesn't exist. - // // Let's save that zone and retry saving the record. - // // Also clear the last known server record if we have one, it's no longer valid. - // let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) - // newPendingDatabaseChanges.append(.saveZone(zone)) - // newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - // shouldClearServerRecord = true - // - // case .unknownItem: - // // We tried to save a record with a locally-cached server record, but that record no longer exists on the server. - // // This might mean that another device deleted the record, but we still have the data for that record locally. - // // We have the choice of either deleting the local data or re-uploading the local data. - // // For this sample app, let's re-upload the local data. - // newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - // shouldClearServerRecord = true - // - // case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, - // .operationCancelled: - // // There are several errors that the sync engine will automatically retry, let's just log and move on. - // Logger.database.debug( - // "Retryable error saving \(failedRecord.recordID): \(failedRecordSave.error)" - // ) - // - // default: - // // We got an error, but we don't know what it is or how to handle it. - // // If you have any sort of telemetry system, you should consider tracking this scenario so you can understand which errors you see in the wild. - // Logger.database.fault( - // "Unknown error saving record \(failedRecord.recordID): \(failedRecordSave.error)" - // ) - // } - // - // if shouldClearServerRecord { - // if var contact = self.appData.contacts[contactID] { - // contact.lastKnownRecord = nil - // self.appData.contacts[contactID] = contact - // } - // } - // } - // - // self.syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) - // self.syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) - // - // // Now that we've processed the batch, save to disk. - // try? self.persistLocalData() } private func handleFetchedRecordZoneChanges( @@ -684,7 +598,6 @@ extension DatabaseWriter { } extension Database { - //func cascade(of: String, whenDeleting: String) func installForeignKeyTrigger( _ childTable: Child.Type, belongsTo: Parent.Type, From bab75840179e3ee760c4f7f63882d87bf731a237 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 May 2025 17:46:05 -0500 Subject: [PATCH 007/581] wip --- Examples/Reminders/CloudKitDatabase.swift | 381 ++++++++++-------- Examples/Reminders/RemindersApp.swift | 16 +- Examples/Reminders/Schema.swift | 455 +++++++++++----------- db1.sqlite | Bin 0 -> 8192 bytes db2.sqlite | Bin 0 -> 8192 bytes 5 files changed, 463 insertions(+), 389 deletions(-) create mode 100644 db1.sqlite create mode 100644 db2.sqlite diff --git a/Examples/Reminders/CloudKitDatabase.swift b/Examples/Reminders/CloudKitDatabase.swift index 96983b86..0c150709 100644 --- a/Examples/Reminders/CloudKitDatabase.swift +++ b/Examples/Reminders/CloudKitDatabase.swift @@ -17,33 +17,59 @@ extension CloudKitDatabase: TestDependencyKey { if shouldReportUnimplemented { reportIssue("TODO") } - return CloudKitDatabase(container: CKContainer(identifier: "default")) + return CloudKitDatabase( + container: CKContainer(identifier: "default"), + tables: [] + ) } } -// TODO: fix sendable by either making actor or locking mutable state -class CloudKitDatabase: @unchecked Sendable { +actor CloudKitDatabase { @Dependency(\.defaultDatabase) var database let container: CKContainer - let syncEngine: CKSyncEngine + var syncEngine: CKSyncEngine! var stateSerialization: CKSyncEngine.State.Serialization? - let delegate: Delegate + let tables: [any StructuredQueries.Table.Type] + var delegate: Delegate - init(container: CKContainer) { + init( + container: CKContainer, + tables: [any StructuredQueries.Table.Type] + ) { self.container = container self.delegate = Delegate(container: container) + self.tables = tables let stateSerializationData = UserDefaults.standard.data( forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) ) ?? Data() - stateSerialization = withErrorReporting { - try JSONDecoder() - .decode( - CKSyncEngine.State.Serialization.self, - from: stateSerializationData - ) + stateSerialization = try? JSONDecoder().decode( + CKSyncEngine.State.Serialization.self, + from: stateSerializationData + ) + let configuration = CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: stateSerialization, + delegate: delegate + ) + syncEngine = CKSyncEngine(configuration) + delegate.syncEngine = syncEngine + Task { + await saveZones() } + } + + deinit { + print("?!?!?!") + } + + func restartSyncEngine() { + UserDefaults.standard.removeObject( + forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) + ) + stateSerialization = nil + self.delegate = Delegate(container: container) let configuration = CKSyncEngine.Configuration( database: container.privateCloudDatabase, stateSerialization: stateSerialization, @@ -51,12 +77,13 @@ class CloudKitDatabase: @unchecked Sendable { ) syncEngine = CKSyncEngine(configuration) delegate.syncEngine = syncEngine + saveZones() } - func saveZones(tableNames: [String]) { + func saveZones() { syncEngine.state.add( - pendingDatabaseChanges: tableNames.map { - .saveZone(CKRecordZone(zoneName: $0)) + pendingDatabaseChanges: tables.map { + .saveZone(CKRecordZone(zoneName: $0.tableName)) } ) } @@ -103,12 +130,12 @@ class CloudKitDatabase: @unchecked Sendable { #if DEBUG func deleteAllRecords() async throws { - let tableNames = try await database.read { try $0.tableNames } - for tableName in tableNames { - let zoneID = CKRecordZone.ID(zoneName: tableName) - syncEngine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) - try await syncEngine.sendChanges() - } + syncEngine.state.add( + pendingDatabaseChanges: tables.map { table in + .deleteZone(CKRecordZone.ID(zoneName: table.tableName)) + } + ) + try await syncEngine.sendChanges() } #endif } @@ -188,95 +215,98 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { for failedRecordSave in changes.failedRecordSaves { // TODO: do this switch failedRecordSave.error.code { - // case .internalError: - // <#code#> - // case .partialFailure: - // <#code#> - // case .networkUnavailable: - // <#code#> - // case .networkFailure: - // <#code#> - // case .badContainer: - // <#code#> - // case .serviceUnavailable: - // <#code#> - // case .requestRateLimited: - // <#code#> - // case .missingEntitlement: - // <#code#> - // case .notAuthenticated: - // <#code#> - // case .permissionFailure: - // <#code#> + // case .internalError: + // <#code#> + // case .partialFailure: + // <#code#> + // case .networkUnavailable: + // <#code#> + // case .networkFailure: + // <#code#> + // case .badContainer: + // <#code#> + // case .serviceUnavailable: + // <#code#> + // case .requestRateLimited: + // <#code#> + // case .missingEntitlement: + // <#code#> + // case .notAuthenticated: + // <#code#> + // case .permissionFailure: + // <#code#> case .unknownItem: print("") - // case .invalidArguments: - // <#code#> - // case .resultsTruncated: - // <#code#> + // case .invalidArguments: + // <#code#> + // case .resultsTruncated: + // <#code#> case .serverRecordChanged: guard let serverRecord = failedRecordSave.error.serverRecord else { continue } try db.cacheNewRecordIfNewer(serverRecord) try serverRecord.upsertIfNewer(db: db) - print(serverRecord.recordID, failedRecordSave.record.recordID, serverRecord.recordID == failedRecordSave.record.recordID) + print( + serverRecord.recordID, + failedRecordSave.record.recordID, + serverRecord.recordID == failedRecordSave.record.recordID + ) newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) - // case .serverRejectedRequest: - // <#code#> - // case .assetFileNotFound: - // <#code#> - // case .assetFileModified: - // <#code#> - // case .incompatibleVersion: - // <#code#> - // case .constraintViolation: - // <#code#> - // case .operationCancelled: - // <#code#> - // case .changeTokenExpired: - // <#code#> - // case .batchRequestFailed: - // <#code#> - // case .zoneBusy: - // <#code#> - // case .badDatabase: - // <#code#> - // case .quotaExceeded: - // <#code#> + // case .serverRejectedRequest: + // <#code#> + // case .assetFileNotFound: + // <#code#> + // case .assetFileModified: + // <#code#> + // case .incompatibleVersion: + // <#code#> + // case .constraintViolation: + // <#code#> + // case .operationCancelled: + // <#code#> + // case .changeTokenExpired: + // <#code#> + // case .batchRequestFailed: + // <#code#> + // case .zoneBusy: + // <#code#> + // case .badDatabase: + // <#code#> + // case .quotaExceeded: + // <#code#> case .zoneNotFound: // TODO: recreate zone if it matches a table name? let zone = CKRecordZone(zoneID: failedRecordSave.record.recordID.zoneID) newPendingDatabaseChanges.append(.saveZone(zone)) newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) - - // case .limitExceeded: - // <#code#> - // case .userDeletedZone: - // <#code#> - // case .tooManyParticipants: - // <#code#> - // case .alreadyShared: - // <#code#> - // case .referenceViolation: - // <#code#> - // case .managedAccountRestricted: - // <#code#> - // case .participantMayNeedVerification: - // <#code#> - // case .serverResponseLost: - // <#code#> - // case .assetNotAvailable: - // <#code#> - // case .accountTemporarilyUnavailable: - // <#code#> + // case .limitExceeded: + // <#code#> + // case .userDeletedZone: + // <#code#> + // case .tooManyParticipants: + // <#code#> + // case .alreadyShared: + // <#code#> + // case .referenceViolation: + // <#code#> + // case .managedAccountRestricted: + // <#code#> + // case .participantMayNeedVerification: + // <#code#> + // case .serverResponseLost: + // <#code#> + // case .assetNotAvailable: + // <#code#> + // case .accountTemporarilyUnavailable: + // <#code#> case .networkFailure, - .networkUnavailable, - .zoneBusy, - .serviceUnavailable, - .notAuthenticated, - .operationCancelled: + .networkUnavailable, + .zoneBusy, + .serviceUnavailable, + .notAuthenticated, + .operationCancelled: print("") default: reportIssue("Unhandled error: \(failedRecordSave.error.code)") @@ -289,10 +319,8 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { } // TODO: double check this is correct. the sample code doesn't have this - try database.write { db in - for deletedRecordID in changes.deletedRecordIDs { - try deletedRecordID.delete(db: db) - } + for deletedRecordID in changes.deletedRecordIDs { + try deletedRecordID.delete(db: db) } } } @@ -488,7 +516,6 @@ extension CKRecord { .fetchOne(db) ?? nil - if let userModificationDate, userModificationDate > (modificationDate ?? .distantPast) { @@ -572,32 +599,8 @@ private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression } } -extension DatabaseWriter { - func setUp(migrator: DatabaseMigrator, body: (inout DatabaseMigrator) throws -> Void) throws { - var migrator = migrator - try body(&migrator) - migrator.registerMigration("Create SharingGRDB tables") { db in - try #sql( - """ - CREATE TABLE "sharing_grdb_cloudkit" ( - "tableName" TEXT NOT NULL, - "primaryKey" TEXT NOT NULL, - "recordData" BLOB, - "userModificationDate" TEXT, - PRIMARY KEY("tableName", "primaryKey") - ) - """ - ) - .execute(db) - } - try write { db in - try installTriggers(db: db) - } - try migrator.migrate(self) - } -} - extension Database { + // TODO: Can this be done automatically by looking at the schema of the tables? func installForeignKeyTrigger( _ childTable: Child.Type, belongsTo: Parent.Type, @@ -618,7 +621,7 @@ extension Database { } extension DatabaseFunction { - convenience init(name: String, function: @escaping @Sendable (String, String) -> Void) { + convenience init(name: String, function: @escaping @Sendable (String, String) async -> Void) { self.init(name, argumentCount: 2) { arguments in guard let tableName = String.fromDatabaseValue(arguments[0]), @@ -626,81 +629,133 @@ extension DatabaseFunction { else { return 0 } - function(tableName, id) + Task { await function(tableName, id) } return 0 } } } -func installTriggers(db: Database) throws { - @Dependency(\.cloudKitDatabase) var cloudKitDatabase +extension Database { + func setUpCloudKit( + containerIdentifier: String, + tables: [any StructuredQueries.Table.Type] + ) throws { + @Dependency(\.context) var context + guard context == .live else { return } + + let cloudKitDatabase = CloudKitDatabase( + container: CKContainer( + identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" + ), + tables: [ + Reminder.self, + RemindersList.self, + Tag.self, + ReminderTag.self, + ] + ) + prepareDependencies { + $0.cloudKitDatabase = cloudKitDatabase + } + + try? FileManager.default + .createDirectory( + at: URL.applicationSupportDirectory, + withIntermediateDirectories: false + ) + let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") + logger.info("open \(url.absoluteString)") + let database = try DatabasePool(path: url.absoluteString) + var migrator = DatabaseMigrator() + migrator.registerMigration("Create SharingGRDB tables") { db in + try #sql( + """ + CREATE TABLE "sharing_grdb_cloudkit" ( + "tableName" TEXT NOT NULL, + "primaryKey" TEXT NOT NULL, + "recordData" BLOB, + "userModificationDate" TEXT, + PRIMARY KEY("tableName", "primaryKey") + ) + """ + ) + .execute(db) + } + try migrator.migrate(database) + try execute( + literal: """ + ATTACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" + """ + ) + try execute( + literal: """ + SELECT * FROM "sharing_grdb_cloudkit" + """ + ) + try installTriggers(db: self, cloudKitDatabase: cloudKitDatabase) + // TODO: look at schema to create triggers for foreign key cascading + } +} + +func installTriggers( + db: Database, + cloudKitDatabase: CloudKitDatabase +) throws { db.add( function: DatabaseFunction( name: "didInsert", - function: cloudKitDatabase.didInsert(tableName:id:) + function: { await cloudKitDatabase.didInsert(tableName: $0, id: $1) } ) ) db.add( function: DatabaseFunction( name: "didUpdate", - function: cloudKitDatabase.didUpdate(tableName:id:) + function: { await cloudKitDatabase.didUpdate(tableName: $0, id: $1) } ) ) db.add( function: DatabaseFunction( name: "willDelete", - function: cloudKitDatabase.willDelete(tableName:id:) + function: { await cloudKitDatabase.willDelete(tableName: $0, id: $1) } ) ) - db.add(function: DatabaseFunction("currentDate", argumentCount: 0, function: { _ in - Date() - })) - let tableNames = try db.tableNames - cloudKitDatabase.saveZones(tableNames: tableNames) - for tableName in tableNames { - try Trigger.delete(tableName: tableName).sql + db.add( + function: DatabaseFunction( + "currentDate", + argumentCount: 0, + function: { _ in + Date() + } + ) + ) + for table in cloudKitDatabase.tables { + try Trigger.delete(tableName: table.tableName).sql .execute(db) - try Trigger.insert(tableName: tableName).sql + try Trigger.insert(tableName: table.tableName).sql .execute(db) - try Trigger.update(tableName: tableName).sql + try Trigger.update(tableName: table.tableName).sql .execute(db) - try #sql(""" - CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: tableName)_userModificationDate" - AFTER UPDATE ON "\(raw: tableName)" FOR EACH ROW BEGIN + try #sql( + """ + CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" + AFTER UPDATE ON \(table) FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit" ("tableName", "primaryKey", "userModificationDate") VALUES ( - '\(raw: tableName)', + '\(raw: table.tableName)', new."id", currentDate() ) ON CONFLICT("tableName", "primaryKey") DO UPDATE SET "userModificationDate" = excluded."userModificationDate"; END - """) + """ + ) .execute(db) } } -extension Database { - var tableNames: [String] { - get throws { - try #sql( - """ - SELECT "name" FROM "sqlite_master" - WHERE "type" = 'table' - AND "name" NOT LIKE 'sqlite_%' - AND "name" NOT LIKE 'grdb_%' - AND "name" NOT LIKE 'sharing_grdb_%' - """, - as: String.self - ) - .fetchAll(self) - } - } -} - struct Trigger { let idColumn: String let function: String diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 66787994..00383c1f 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -6,11 +6,17 @@ import SwiftUI struct RemindersApp: App { init() { try! prepareDependencies { - $0.cloudKitDatabase = CloudKitDatabase( - container: CKContainer( - identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" - ) - ) +// $0.cloudKitDatabase = CloudKitDatabase( +// container: CKContainer( +// identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" +// ), +// tables: [ +// Reminder.self, +// RemindersList.self, +// Tag.self, +// ReminderTag.self +// ] +// ) $0.defaultDatabase = try Reminders.appDatabase() } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 7601b468..a8a9f411 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -123,123 +123,134 @@ func appDatabase() throws -> any DatabaseWriter { migrator.eraseDatabaseOnSchemaChange = true #endif - try database.setUp(migrator: migrator) { migrator in - migrator.registerMigration("Create initial tables") { db in - try #sql( - """ - CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), - "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), - "title" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), - "dueDate" TEXT, - "isCompleted" INTEGER NOT NULL DEFAULT 0, - "isFlagged" INTEGER NOT NULL DEFAULT 0, - "notes" TEXT, - "priority" INTEGER, - "remindersListID" TEXT NOT NULL, - "title" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL COLLATE NOCASE UNIQUE - ) STRICT - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "remindersTags" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "reminderID" TEXT NOT NULL, - "tagID" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - } - migrator.registerMigration("Add 'position' column to 'remindersLists'") { db in - try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 - """ - ) - .execute(db) - try #sql( - """ - CREATE TRIGGER "default_position_reminders_lists" - AFTER INSERT ON "remindersLists" - FOR EACH ROW BEGIN - UPDATE "remindersLists" - SET "position" = (SELECT max("position") + 1 FROM "remindersLists") - WHERE "id" = NEW."id"; - END - """ - ) - .execute(db) - } - migrator.registerMigration("Add 'position' column to 'reminders'") { db in - try #sql( - """ - ALTER TABLE "reminders" - ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 - """ + migrator.registerMigration("Create initial tables") { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), + "title" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "dueDate" TEXT, + "isCompleted" INTEGER NOT NULL DEFAULT 0, + "isFlagged" INTEGER NOT NULL DEFAULT 0, + "notes" TEXT, + "priority" INTEGER, + "remindersListID" TEXT NOT NULL, + "title" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "tags" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "title" TEXT NOT NULL COLLATE NOCASE UNIQUE + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "remindersTags" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "reminderID" TEXT NOT NULL, + "tagID" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + } + migrator.registerMigration("Add 'position' column to 'remindersLists'") { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 + """ + ) + .execute(db) + try #sql( + """ + CREATE TRIGGER "default_position_reminders_lists" + AFTER INSERT ON "remindersLists" + FOR EACH ROW BEGIN + UPDATE "remindersLists" + SET "position" = (SELECT max("position") + 1 FROM "remindersLists") + WHERE "id" = NEW."id"; + END + """ + ) + .execute(db) + } + migrator.registerMigration("Add 'position' column to 'reminders'") { db in + try #sql( + """ + ALTER TABLE "reminders" + ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 + """ + ) + .execute(db) + // Backfill position of reminders based on their completion status and due date. + try #sql( + """ + WITH "reminderPositions" AS ( + SELECT + "reminders"."id", + ROW_NUMBER() OVER (PARTITION BY "remindersListID" ORDER BY id) - 1 AS "position" + FROM "reminders" + ORDER BY NOT "isCompleted", "dueDate" DESC ) - .execute(db) - // Backfill position of reminders based on their completion status and due date. - try #sql( - """ - WITH "reminderPositions" AS ( - SELECT - "reminders"."id", - ROW_NUMBER() OVER (PARTITION BY "remindersListID" ORDER BY id) - 1 AS "position" - FROM "reminders" - ORDER BY NOT "isCompleted", "dueDate" DESC - ) + UPDATE "reminders" + SET "position" = "reminderPositions"."position" + FROM "reminderPositions" + WHERE "reminders"."id" = "reminderPositions"."id" + """ + ) + .execute(db) + try #sql( + """ + CREATE TRIGGER "default_position_reminders" + AFTER INSERT ON "reminders" + FOR EACH ROW BEGIN UPDATE "reminders" - SET "position" = "reminderPositions"."position" - FROM "reminderPositions" - WHERE "reminders"."id" = "reminderPositions"."id" - """ - ) - .execute(db) - try #sql( - """ - CREATE TRIGGER "default_position_reminders" - AFTER INSERT ON "reminders" - FOR EACH ROW BEGIN - UPDATE "reminders" - SET "position" = (SELECT max("position") + 1 FROM "reminders") - WHERE "id" = NEW."id"; - END - """ - ) - .execute(db) - } + SET "position" = (SELECT max("position") + 1 FROM "reminders") + WHERE "id" = NEW."id"; + END + """ + ) + .execute(db) + } - #if DEBUG && targetEnvironment(simulator) - if context != .test { - migrator.registerMigration("Seed sample data") { db in - try db.seedSampleData() - } + #if DEBUG && targetEnvironment(simulator) + if context != .test { + migrator.registerMigration("Seed sample data") { db in + try db.seedSampleData() } - #endif - } + } + #endif + + try migrator.migrate(database) try database.write { db in + try db.setUpCloudKit( + containerIdentifier: "iCloud.co.pointfree.sharing-grdb.Reminders", + tables: [ + Reminder.self, + RemindersList.self, + Tag.self, + ReminderTag.self, + ] + ) + + // TODO: hopefully this can be automated try db.installForeignKeyTrigger( RemindersList.self, belongsTo: Reminder.self, @@ -265,119 +276,121 @@ let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { func seedSampleData() throws { - return - try seed { - RemindersList( - id: UUID(1), - color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), - title: "Personal" - ) - RemindersList( - id: UUID(2), - color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), - title: "Family" - ) - RemindersList( - id: UUID(3), - color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), - title: "Business" - ) + // TODO: add a dedicated seed button + return () - Reminder( - id: UUID(1), - notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - remindersListID: UUID(1), - title: "Groceries" - ) - Reminder( - id: UUID(2), - dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), - isFlagged: true, - remindersListID: UUID(1), - title: "Haircut" - ) - Reminder( - id: UUID(3), - dueDate: Date(), - notes: "Ask about diet", - priority: .high, - remindersListID: UUID(1), - title: "Doctor appointment" - ) - Reminder( - id: UUID(4), - dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), - isCompleted: true, - remindersListID: UUID(1), - title: "Take a walk" - ) - Reminder( - id: UUID(5), - dueDate: Date(), - remindersListID: UUID(1), - title: "Buy concert tickets" - ) - Reminder( - id: UUID(6), - dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), - isFlagged: true, - priority: .high, - remindersListID: UUID(2), - title: "Pick up kids from school" - ) - Reminder( - id: UUID(7), - dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, - priority: .low, - remindersListID: UUID(2), - title: "Get laundry" - ) - Reminder( - id: UUID(8), - dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), - isCompleted: false, - priority: .high, - remindersListID: UUID(2), - title: "Take out trash" - ) - Reminder( - id: UUID(9), - dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), - notes: """ - Status of tax return - Expenses for next year - Changing payroll company - """, - remindersListID: UUID(3), - title: "Call accountant" - ) - Reminder( - id: UUID(10), - dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, - priority: .medium, - remindersListID: UUID(3), - title: "Send weekly emails" - ) + try seed { + RemindersList( + id: UUID(1), + color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), + title: "Personal" + ) + RemindersList( + id: UUID(2), + color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), + title: "Family" + ) + RemindersList( + id: UUID(3), + color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), + title: "Business" + ) - Tag(id: UUID(1), title: "car") - Tag(id: UUID(2), title: "kids") - Tag(id: UUID(3), title: "someday") - Tag(id: UUID(4), title: "optional") - Tag(id: UUID(5), title: "social") - Tag(id: UUID(6), title: "night") - Tag(id: UUID(7), title: "adulting") + Reminder( + id: UUID(1), + notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", + remindersListID: UUID(1), + title: "Groceries" + ) + Reminder( + id: UUID(2), + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), + isFlagged: true, + remindersListID: UUID(1), + title: "Haircut" + ) + Reminder( + id: UUID(3), + dueDate: Date(), + notes: "Ask about diet", + priority: .high, + remindersListID: UUID(1), + title: "Doctor appointment" + ) + Reminder( + id: UUID(4), + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), + isCompleted: true, + remindersListID: UUID(1), + title: "Take a walk" + ) + Reminder( + id: UUID(5), + dueDate: Date(), + remindersListID: UUID(1), + title: "Buy concert tickets" + ) + Reminder( + id: UUID(6), + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), + isFlagged: true, + priority: .high, + remindersListID: UUID(2), + title: "Pick up kids from school" + ) + Reminder( + id: UUID(7), + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + priority: .low, + remindersListID: UUID(2), + title: "Get laundry" + ) + Reminder( + id: UUID(8), + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), + isCompleted: false, + priority: .high, + remindersListID: UUID(2), + title: "Take out trash" + ) + Reminder( + id: UUID(9), + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), + notes: """ + Status of tax return + Expenses for next year + Changing payroll company + """, + remindersListID: UUID(3), + title: "Call accountant" + ) + Reminder( + id: UUID(10), + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + priority: .medium, + remindersListID: UUID(3), + title: "Send weekly emails" + ) - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(3)) - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(4)) - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(7)) - ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(3)) - ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(4)) - ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(7)) - ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(1)) - ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(2)) - } + Tag(id: UUID(1), title: "car") + Tag(id: UUID(2), title: "kids") + Tag(id: UUID(3), title: "someday") + Tag(id: UUID(4), title: "optional") + Tag(id: UUID(5), title: "social") + Tag(id: UUID(6), title: "night") + Tag(id: UUID(7), title: "adulting") + + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(3)) + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(4)) + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(7)) + ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(3)) + ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(4)) + ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(7)) + ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(1)) + ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(2)) + } } } #endif diff --git a/db1.sqlite b/db1.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..62db540e09b02cf822221cab6c752461efce379c GIT binary patch literal 8192 zcmeI#u?oU45C-5&DuSTIO}ZVKbP>eW!7AOlsPzFVMk#28l4o=9fm~dC1e3NyH(iDQ zxZEWn5b|x=ZIav?KHbV|W_eCS5~9CkCL-Ak*QyY%cAB|we*G;ZO^e6Cof7I!E)fub z00bZa0SG_<0uX=z1Rwwb2>dLt5ZzH2ioMMa7gLp4UFCWkM`_Gyv`S*u^`_>r&X1b& z!kV)wC+%ze-#k!HQg8Kg4FLfNKmY;|fB*y_009U<00Izzz~2f4ffQ~?;j`C%#s>{% B9;E;P literal 0 HcmV?d00001 diff --git a/db2.sqlite b/db2.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..8ce78514800173bbed886292664a9d118ed494fa GIT binary patch literal 8192 zcmeI%y9&ZE6b9gP678hKO}d3lx;W_SV3lrNygfk0Cp` z$vJHZ1imd~yG}A^_32hzQ>SM%Am-jeYa$ZO^sNf}+G)PaR{npBph^Dd`(3Q=mOQVI^F5Zsk4xc!Dt EAO3kBrvLx| literal 0 HcmV?d00001 From 0bdba2819fbc73a52015367f151f6ab819f1da65 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 May 2025 17:52:47 -0500 Subject: [PATCH 008/581] wip --- Examples/Reminders/ReminderForm.swift | 11 +++----- Examples/Reminders/ReminderRow.swift | 4 +-- Examples/Reminders/RemindersApp.swift | 11 -------- Examples/Reminders/RemindersDetail.swift | 10 +++---- Examples/Reminders/RemindersLists.swift | 6 ++--- Examples/Reminders/Schema.swift | 26 ++++++++----------- Examples/Reminders/TagsForm.swift | 2 +- .../QueryCursor.swift | 4 +++ 8 files changed, 30 insertions(+), 44 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 4c93b4ba..0675ce74 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -18,10 +18,7 @@ struct ReminderFormView: View { if let existingReminder { reminder = Reminder.Draft(existingReminder) } else { - var reminder = Reminder.Draft(remindersListID: remindersList.id) - // TODO: better way to handle default UUID? - reminder.id = UUID() - self.reminder = reminder + self.reminder = Reminder.Draft(id: UUID(), remindersListID: remindersList.id) } } @@ -140,7 +137,7 @@ struct ReminderFormView: View { try Tag .order(by: \.title) .join(ReminderTag.all) { $0.id.eq($1.tagID) } - .where { $1.reminderID.eq(#bind(reminderID)) } + .where { $1.reminderID.eq(reminderID) } .select { tag, _ in tag } .fetchAll(db) } @@ -176,7 +173,7 @@ struct ReminderFormView: View { try database.write { db in let reminderID = try Reminder.upsert(reminder).returning(\.id).fetchOne(db)! try ReminderTag - .where { $0.reminderID.eq(#bind(reminderID)) } + .where { $0.reminderID.eq(reminderID) } .delete() .execute(db) try ReminderTag.insert( @@ -213,7 +210,7 @@ struct ReminderFormPreview: PreviewProvider { let remindersList = try RemindersList.all.fetchOne(db)! return ( remindersList, - try Reminder.where { $0.remindersListID.eq(#bind(remindersList.id)) }.fetchOne(db)! + try Reminder.where { $0.remindersListID.eq(remindersList.id) }.fetchOne(db)! ) } } diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index 54d40df7..1e956b74 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -86,7 +86,7 @@ struct ReminderRow: View { withErrorReporting { try database.write { db in try Reminder - .find(UUID.LowercasedRepresentation(queryOutput: reminder.id)) + .find(reminder.id) .update { $0.isFlagged.toggle() } .execute(db) } @@ -129,7 +129,7 @@ struct ReminderRow: View { try database.write { db in // isCompleted = try Reminder - .find(UUID.LowercasedRepresentation(queryOutput: reminder.id)) + .find(reminder.id) .update { $0.isCompleted.toggle() } // .returning(\.isCompleted) .fetchOne(db)// ?? isCompleted diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 00383c1f..d7691784 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -6,17 +6,6 @@ import SwiftUI struct RemindersApp: App { init() { try! prepareDependencies { -// $0.cloudKitDatabase = CloudKitDatabase( -// container: CKContainer( -// identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" -// ), -// tables: [ -// Reminder.self, -// RemindersList.self, -// Tag.self, -// ReminderTag.self -// ] -// ) $0.defaultDatabase = try Reminders.appDatabase() } } diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 6d1c6464..604517d8 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -135,14 +135,14 @@ struct RemindersDetailView: View { var ids = reminderStates.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) try Reminder - .where { $0.id.in(ids.map { #bind($0) }) } + .where { $0.id.in(ids) } .update { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) $0.position = rest - .reduce(Case($0.id).when(#bind(first.element), then: first.offset)) { cases, id in - cases.when(#bind(id.element), then: id.offset) + .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in + cases.when(id.element, then: id.offset) } .else($0.position) } @@ -206,9 +206,9 @@ struct RemindersDetailView: View { case .all: !reminder.isCompleted case .completed: reminder.isCompleted case .flagged: reminder.isFlagged - case .list(let list): reminder.remindersListID.eq(#bind(list.id)) + case .list(let list): reminder.remindersListID.eq(list.id) case .scheduled: reminder.isScheduled - case .tags(let tags): tag.id.ifnull(#bind(UUID(0))).in(tags.map { #bind($0.id) }) + case .tags(let tags): tag.id.ifnull(UUID(0)).in(tags.map(\.id)) case .today: reminder.isToday } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 28741548..2a9eef3b 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -234,14 +234,14 @@ struct RemindersListsView: View { var ids = remindersLists.map(\.remindersList.id) ids.move(fromOffsets: source, toOffset: destination) try RemindersList - .where { $0.id.in(ids.map { #bind($0) }) } + .where { $0.id.in(ids) } .update { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) $0.position = rest - .reduce(Case($0.id).when(#bind(first.element), then: first.offset)) { cases, id in - cases.when(#bind(id.element), then: id.offset) + .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in + cases.when(id.element, then: id.offset) } .else($0.position) } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index a8a9f411..03fd733a 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -7,8 +7,7 @@ import SwiftUI @Table struct RemindersList: Hashable, Identifiable { - @Column(as: UUID.LowercasedRepresentation.self) - var id: UUID + let id: UUID @Column(as: Color.HexRepresentation.self) var color = Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255) var position = 0 @@ -17,15 +16,12 @@ struct RemindersList: Hashable, Identifiable { @Table struct Reminder: Equatable, Identifiable { - @Column(as: UUID.LowercasedRepresentation.self) - var id: UUID - @Column(as: Date.ISO8601Representation?.self) + let id: UUID var dueDate: Date? var isCompleted = false var isFlagged = false var notes = "" var priority: Priority? - @Column(as: UUID.LowercasedRepresentation.self) var remindersListID: RemindersList.ID var position = 0 var title = "" @@ -67,8 +63,7 @@ enum Priority: Int, QueryBindable { @Table struct Tag: Hashable, Identifiable { - @Column(as: UUID.LowercasedRepresentation.self) - var id: UUID + let id: UUID var title = "" } @@ -86,12 +81,8 @@ extension Tag.TableColumns { @Table("remindersTags") struct ReminderTag: Hashable, Identifiable { - @Column(as: UUID.LowercasedRepresentation.self) - var id: UUID - - @Column(as: UUID.LowercasedRepresentation.self) + let id: UUID var reminderID: Reminder.ID - @Column(as: UUID.LowercasedRepresentation.self) var tagID: Tag.ID } @@ -144,7 +135,9 @@ func appDatabase() throws -> any DatabaseWriter { "notes" TEXT, "priority" INTEGER, "remindersListID" TEXT NOT NULL, - "title" TEXT NOT NULL + "title" TEXT NOT NULL, + + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -163,7 +156,10 @@ func appDatabase() throws -> any DatabaseWriter { CREATE TABLE "remindersTags" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "reminderID" TEXT NOT NULL, - "tagID" TEXT NOT NULL + "tagID" TEXT NOT NULL, + + FOREIGN KEY("reminderID") REFERENCES "reminders"("id") ON DELETE CASCADE, + FOREIGN KEY("tagID") REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT """ ) diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 96b2413a..8ba5b1a1 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -56,7 +56,7 @@ struct TagsView: View { let rest = try Tag - .where { !$0.id.in(top.map { #bind($0.id) }) } + .where { !$0.id.in(top.map(\.id)) } .order(by: \.title) .fetchAll(db) diff --git a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift b/Sources/StructuredQueriesGRDBCore/QueryCursor.swift index f1eb8baf..b680f214 100644 --- a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift +++ b/Sources/StructuredQueriesGRDBCore/QueryCursor.swift @@ -88,6 +88,8 @@ extension QueryBinding { switch self { case let .blob(blob): return Data(blob).databaseValue + case let .date(date): + return date.databaseValue case let .double(double): return double.databaseValue case let .int(int): @@ -96,6 +98,8 @@ extension QueryBinding { return .null case let .text(text): return text.databaseValue + case .uuid(let uuid): + return uuid.databaseValue case let .invalid(error): throw error } From f0ff1d1a9a894fe2f52f817937ae2a7e1cdd8698 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 13 May 2025 13:19:47 -0700 Subject: [PATCH 009/581] wip --- Examples/Reminders/RemindersListForm.swift | 4 +--- Examples/Reminders/Schema.swift | 6 +++--- .../QueryCursor.swift | 2 +- .../SQLiteQueryDecoder.swift | 20 +++++++++++++++++++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 36146091..448491d0 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -34,9 +34,7 @@ struct RemindersListForm: View { Button("Save") { withErrorReporting { try database.write { db in - let query = RemindersList.upsert(remindersList) - print(query.queryFragment.debugDescription) - try query + try RemindersList.upsert(remindersList) .execute(db) } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 03fd733a..8aa1994e 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -94,10 +94,10 @@ func appDatabase() throws -> any DatabaseWriter { configuration.prepareDatabase { db in #if DEBUG db.trace(options: .profile) { - if context == .live { - logger.debug("\($0.expandedDescription)") - } else { + if context == .preview { print("\($0.expandedDescription)") + } else { + logger.debug("\($0.expandedDescription)") } } #endif diff --git a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift b/Sources/StructuredQueriesGRDBCore/QueryCursor.swift index b680f214..554f58db 100644 --- a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift +++ b/Sources/StructuredQueriesGRDBCore/QueryCursor.swift @@ -99,7 +99,7 @@ extension QueryBinding { case let .text(text): return text.databaseValue case .uuid(let uuid): - return uuid.databaseValue + return uuid.uuidString.lowercased().databaseValue case let .invalid(error): throw error } diff --git a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift b/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift index 75f14a26..21cbaf67 100644 --- a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift +++ b/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift @@ -1,3 +1,4 @@ +import Foundation import GRDBSQLite import StructuredQueriesCore @@ -61,4 +62,23 @@ struct SQLiteQueryDecoder: QueryDecoder { mutating func decode(_ columnType: Int.Type) throws -> Int? { try decode(Int64.self).map(Int.init) } + + @inlinable + mutating func decode(_ columnType: Date.Type) throws -> Date? { + try decode(String.self).map { try Date.ISO8601Representation(iso8601String: $0).queryOutput } + } + + @inlinable + mutating func decode(_ columnType: UUID.Type) throws -> UUID? { + guard let uuidString = try decode(String.self) else { return nil } + guard let uuid = UUID(uuidString: uuidString) else { throw InvalidUUID() } + return uuid + } + +} + +@usableFromInline +struct InvalidUUID: Error { + @usableFromInline + init() {} } From 331a3595dc9d811ffde446e0da99d12d8dc07081 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 13 May 2025 14:00:45 -0700 Subject: [PATCH 010/581] wip --- Examples/Reminders/Schema.swift | 17 -- .../SharingGRDBCore/CloudKit.swift | 183 +++++++++++++----- .../SQLiteQueryDecoder.swift | 1 - 3 files changed, 131 insertions(+), 70 deletions(-) rename Examples/Reminders/CloudKitDatabase.swift => Sources/SharingGRDBCore/CloudKit.swift (84%) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 8aa1994e..034174cd 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -245,23 +245,6 @@ func appDatabase() throws -> any DatabaseWriter { ReminderTag.self, ] ) - - // TODO: hopefully this can be automated - try db.installForeignKeyTrigger( - RemindersList.self, - belongsTo: Reminder.self, - through: Reminder.remindersListID - ) - try db.installForeignKeyTrigger( - Tag.self, - belongsTo: ReminderTag.self, - through: ReminderTag.tagID - ) - try db.installForeignKeyTrigger( - Reminder.self, - belongsTo: ReminderTag.self, - through: ReminderTag.reminderID - ) } return database diff --git a/Examples/Reminders/CloudKitDatabase.swift b/Sources/SharingGRDBCore/CloudKit.swift similarity index 84% rename from Examples/Reminders/CloudKitDatabase.swift rename to Sources/SharingGRDBCore/CloudKit.swift index 0c150709..f3425c0a 100644 --- a/Examples/Reminders/CloudKitDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit.swift @@ -1,19 +1,19 @@ +#if canImport(CloudKit) import CloudKit import Dependencies -import SharingGRDB +import OSLog extension DependencyValues { - var cloudKitDatabase: CloudKitDatabase { - get { - self[CloudKitDatabase.self] - } - set { - self[CloudKitDatabase.self] = newValue - } + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + public var cloudKitDatabase: CloudKitDatabase { + get { self[CloudKitDatabase.self] } + set { self[CloudKitDatabase.self] = newValue } } } + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension CloudKitDatabase: TestDependencyKey { - static var testValue: CloudKitDatabase { + public static var testValue: CloudKitDatabase { if shouldReportUnimplemented { reportIssue("TODO") } @@ -24,18 +24,19 @@ extension CloudKitDatabase: TestDependencyKey { } } -actor CloudKitDatabase { +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +public actor CloudKitDatabase { @Dependency(\.defaultDatabase) var database let container: CKContainer var syncEngine: CKSyncEngine! var stateSerialization: CKSyncEngine.State.Serialization? - let tables: [any StructuredQueries.Table.Type] + let tables: [any StructuredQueriesCore.Table.Type] var delegate: Delegate init( container: CKContainer, - tables: [any StructuredQueries.Table.Type] + tables: [any StructuredQueriesCore.Table.Type] ) { self.container = container self.delegate = Delegate(container: container) @@ -129,7 +130,7 @@ actor CloudKitDatabase { } #if DEBUG - func deleteAllRecords() async throws { + public func deleteAllRecords() async throws { syncEngine.state.add( pendingDatabaseChanges: tables.map { table in .deleteZone(CKRecordZone.ID(zoneName: table.tableName)) @@ -140,6 +141,7 @@ actor CloudKitDatabase { #endif } +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { @Dependency(\.defaultDatabase) var database let container: CKContainer @@ -348,7 +350,7 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { try database.write { db in for deletion in changes.deletions { let tableName = deletion.zoneID.zoneName - try #sql( + try SQLQueryExpression( """ DELETE FROM "\(raw: tableName)" """ @@ -404,6 +406,7 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { } } +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension CKRecord { func update(with row: Row) { for columnName in row.columnNames { @@ -463,7 +466,7 @@ extension Database { let archiver = NSKeyedArchiver(requiringSecureCoding: true) newRecord.encodeSystemFields(with: archiver) // TODO: should we use userModificationDate based on record.modificationDate? - try #sql( + try SQLQueryExpression( """ INSERT INTO "sharing_grdb_cloudkit" ("tableName", "primaryKey", "recordData", "userModificationDate") @@ -482,7 +485,7 @@ extension Database { } func fetchLastCachedRecord(id recordID: CKRecord.ID) throws -> CKRecord { - return try #sql( + return try SQLQueryExpression( """ SELECT "recordData" FROM "sharing_grdb_cloudkit" @@ -502,10 +505,11 @@ extension Database { } } +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension CKRecord { func upsertIfNewer(db: Database) throws { let userModificationDate = - try #sql( + try SQLQueryExpression( """ SELECT "userModificationDate" FROM "sharing_grdb_cloudkit" WHERE "tableName" = \(bind: recordID.tableName) @@ -560,7 +564,7 @@ extension CKRecord { extension CKRecord.ID { func delete(db: Database) throws { - try #sql( + try SQLQueryExpression( """ DELETE FROM "\(raw: tableName)" WHERE "id" = \(bind: primaryKey) @@ -572,7 +576,7 @@ extension CKRecord.ID { extension CKRecordZone.ID { func deleteAll(db: Database) throws { - try #sql( + try SQLQueryExpression( """ DELETE FROM "\(raw: zoneName)" """ @@ -599,27 +603,6 @@ private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression } } -extension Database { - // TODO: Can this be done automatically by looking at the schema of the tables? - func installForeignKeyTrigger( - _ childTable: Child.Type, - belongsTo: Parent.Type, - through foreignKey: TableColumn - ) throws { - try #sql( - """ - CREATE TEMP TRIGGER "foreign_key_\(raw: Child.tableName)_belongsTo_\(raw: Parent.tableName)" - AFTER DELETE ON \(Child.self) - FOR EACH ROW BEGIN - DELETE FROM \(Parent.self) - WHERE \(foreignKey) = old.\(raw: Child.columns.primaryKey.name); - END - """ - ) - .execute(self) - } -} - extension DatabaseFunction { convenience init(name: String, function: @escaping @Sendable (String, String) async -> Void) { self.init(name, argumentCount: 2) { arguments in @@ -636,9 +619,10 @@ extension DatabaseFunction { } extension Database { - func setUpCloudKit( + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + public func setUpCloudKit( containerIdentifier: String, - tables: [any StructuredQueries.Table.Type] + tables: [any StructuredQueriesCore.Table.Type] ) throws { @Dependency(\.context) var context guard context == .live else { return } @@ -647,12 +631,7 @@ extension Database { container: CKContainer( identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" ), - tables: [ - Reminder.self, - RemindersList.self, - Tag.self, - ReminderTag.self, - ] + tables: tables ) prepareDependencies { $0.cloudKitDatabase = cloudKitDatabase @@ -668,7 +647,7 @@ extension Database { let database = try DatabasePool(path: url.absoluteString) var migrator = DatabaseMigrator() migrator.registerMigration("Create SharingGRDB tables") { db in - try #sql( + try SQLQueryExpression( """ CREATE TABLE "sharing_grdb_cloudkit" ( "tableName" TEXT NOT NULL, @@ -693,10 +672,10 @@ extension Database { """ ) try installTriggers(db: self, cloudKitDatabase: cloudKitDatabase) - // TODO: look at schema to create triggers for foreign key cascading } } +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) func installTriggers( db: Database, cloudKitDatabase: CloudKitDatabase @@ -735,7 +714,7 @@ func installTriggers( .execute(db) try Trigger.update(tableName: table.tableName).sql .execute(db) - try #sql( + try SQLQueryExpression( """ CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" AFTER UPDATE ON \(table) FOR EACH ROW BEGIN @@ -753,6 +732,102 @@ func installTriggers( """ ) .execute(db) + let foreignKeys = try SQLQueryExpression( + """ + SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) + """, + as: PragmaForeignKey.self + ) + .fetchAll(db) + for foreignKey in foreignKeys { + switch foreignKey.onDelete { + case .cascade: + try SQLQueryExpression( + """ + CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + DELETE FROM \(quote: table.tableName) + WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); + END + """ + ) + .execute(db) + case .restrict: + fatalError("TODO: report issue?") + case .setDefault: + fatalError("TODO: report issue?") + case .setNull: + try SQLQueryExpression( + """ + CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(quote: table.tableName) + SET \(quote: foreignKey.from) = NULL + WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); + END + """ + ) + .execute(db) + case .noAction: + continue + } + + switch foreignKey.onUpdate { + case .cascade: + fatalError("TODO") + case .restrict: + fatalError("TODO") + case .setDefault: + fatalError("TODO") + case .setNull: + fatalError("TODO") + case .noAction: + continue + } + } + } +} + +private struct PragmaForeignKey: QueryDecodable, QueryRepresentable { + enum Action: String, QueryBindable { + case cascade = "CASCADE" + case restrict = "RESTRICT" + case setDefault = "SET DEFAULT" + case setNull = "SET NULL" + case noAction = "NO ACTION" + } + + typealias QueryValue = Self + + let table: String + let from: String + let to: String + let onUpdate: Action + let onDelete: Action + + init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + guard + let table = try decoder.decode(String.self), + let from = try decoder.decode(String.self), + let to = try decoder.decode(String.self), + let onUpdate = try decoder.decode(Action.self), + let onDelete = try decoder.decode(Action.self) + else { + throw QueryDecodingError.missingRequiredColumn + } + self.table = table + self.from = from + self.to = to + self.onUpdate = onUpdate + self.onDelete = onDelete + } + + static var columns: QueryFragment { + """ + "table", "from", "to", "on_update", "on_delete", "match" + """ } } @@ -790,7 +865,7 @@ struct Trigger { ) } var sql: SQLQueryExpression { - #sql( + SQLQueryExpression( """ CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN @@ -800,3 +875,7 @@ struct Trigger { ) } } + +@available(macOS 11, iOS 14, watchOS 7, tvOS 14, *) +private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") +#endif diff --git a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift b/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift index 21cbaf67..b2c40c78 100644 --- a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift +++ b/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift @@ -74,7 +74,6 @@ struct SQLiteQueryDecoder: QueryDecoder { guard let uuid = UUID(uuidString: uuidString) else { throw InvalidUUID() } return uuid } - } @usableFromInline From 495a0a1d0dcd8799e6034d92783c983060126e14 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 13 May 2025 14:17:26 -0700 Subject: [PATCH 011/581] wip --- Examples/Reminders/RemindersApp.swift | 10 +++ Examples/Reminders/Schema.swift | 15 +--- Sources/SharingGRDBCore/CloudKit.swift | 120 +++++++++++-------------- 3 files changed, 65 insertions(+), 80 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index d7691784..d81f66c7 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -7,6 +7,16 @@ struct RemindersApp: App { init() { try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() + $0.cloudKitDatabase = try CloudKitDatabase( + container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), + database: $0.defaultDatabase, + tables: [ + Reminder.self, + RemindersList.self, + Tag.self, + ReminderTag.self, + ] + ) } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 034174cd..043ccd64 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -90,7 +90,7 @@ func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() - configuration.foreignKeysEnabled = false + configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in #if DEBUG db.trace(options: .profile) { @@ -234,19 +234,6 @@ func appDatabase() throws -> any DatabaseWriter { #endif try migrator.migrate(database) - - try database.write { db in - try db.setUpCloudKit( - containerIdentifier: "iCloud.co.pointfree.sharing-grdb.Reminders", - tables: [ - Reminder.self, - RemindersList.self, - Tag.self, - ReminderTag.self, - ] - ) - } - return database } diff --git a/Sources/SharingGRDBCore/CloudKit.swift b/Sources/SharingGRDBCore/CloudKit.swift index f3425c0a..fc5d1efd 100644 --- a/Sources/SharingGRDBCore/CloudKit.swift +++ b/Sources/SharingGRDBCore/CloudKit.swift @@ -17,8 +17,9 @@ extension CloudKitDatabase: TestDependencyKey { if shouldReportUnimplemented { reportIssue("TODO") } - return CloudKitDatabase( + return try! CloudKitDatabase( container: CKContainer(identifier: "default"), + database: try! DatabaseQueue(), tables: [] ) } @@ -26,19 +27,20 @@ extension CloudKitDatabase: TestDependencyKey { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) public actor CloudKitDatabase { - @Dependency(\.defaultDatabase) var database - let container: CKContainer + let database: any DatabaseWriter var syncEngine: CKSyncEngine! var stateSerialization: CKSyncEngine.State.Serialization? let tables: [any StructuredQueriesCore.Table.Type] var delegate: Delegate - init( + public init( container: CKContainer, + database: any DatabaseWriter, tables: [any StructuredQueriesCore.Table.Type] - ) { + ) throws { self.container = container + self.database = database self.delegate = Delegate(container: container) self.tables = tables let stateSerializationData = @@ -54,11 +56,47 @@ public actor CloudKitDatabase { stateSerialization: stateSerialization, delegate: delegate ) - syncEngine = CKSyncEngine(configuration) + let syncEngine = CKSyncEngine(configuration) + self.syncEngine = syncEngine delegate.syncEngine = syncEngine - Task { - await saveZones() + try? FileManager.default + .createDirectory( + at: URL.applicationSupportDirectory, + withIntermediateDirectories: false + ) + let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") + logger.info("open \(url.absoluteString)") + let cloudKitDatabase = try DatabasePool(path: url.absoluteString) + var migrator = DatabaseMigrator() + migrator.registerMigration("Create SharingGRDB tables") { db in + try SQLQueryExpression( + """ + CREATE TABLE "sharing_grdb_cloudkit" ( + "tableName" TEXT NOT NULL, + "primaryKey" TEXT NOT NULL, + "recordData" BLOB, + "userModificationDate" TEXT, + PRIMARY KEY("tableName", "primaryKey") + ) + """ + ) + .execute(db) } + try migrator.migrate(cloudKitDatabase) + try database.write { db in + try db.execute( + literal: """ + ATTACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" + """ + ) + try db.execute( + literal: """ + SELECT * FROM "sharing_grdb_cloudkit" + """ + ) + try installTriggers(db: db, cloudKitDatabase: self) + } + Self.saveZones(syncEngine: syncEngine, tables: tables) } deinit { @@ -81,7 +119,10 @@ public actor CloudKitDatabase { saveZones() } - func saveZones() { + static func saveZones( + syncEngine: CKSyncEngine, + tables: [any StructuredQueriesCore.Table.Type] + ) { syncEngine.state.add( pendingDatabaseChanges: tables.map { .saveZone(CKRecordZone(zoneName: $0.tableName)) @@ -89,6 +130,10 @@ public actor CloudKitDatabase { ) } + func saveZones() { + Self.saveZones(syncEngine: syncEngine, tables: tables) + } + func didInsert(tableName: String, id: String) { syncEngine.state.add( pendingRecordZoneChanges: [ @@ -618,63 +663,6 @@ extension DatabaseFunction { } } -extension Database { - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - public func setUpCloudKit( - containerIdentifier: String, - tables: [any StructuredQueriesCore.Table.Type] - ) throws { - @Dependency(\.context) var context - guard context == .live else { return } - - let cloudKitDatabase = CloudKitDatabase( - container: CKContainer( - identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" - ), - tables: tables - ) - prepareDependencies { - $0.cloudKitDatabase = cloudKitDatabase - } - - try? FileManager.default - .createDirectory( - at: URL.applicationSupportDirectory, - withIntermediateDirectories: false - ) - let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") - logger.info("open \(url.absoluteString)") - let database = try DatabasePool(path: url.absoluteString) - var migrator = DatabaseMigrator() - migrator.registerMigration("Create SharingGRDB tables") { db in - try SQLQueryExpression( - """ - CREATE TABLE "sharing_grdb_cloudkit" ( - "tableName" TEXT NOT NULL, - "primaryKey" TEXT NOT NULL, - "recordData" BLOB, - "userModificationDate" TEXT, - PRIMARY KEY("tableName", "primaryKey") - ) - """ - ) - .execute(db) - } - try migrator.migrate(database) - try execute( - literal: """ - ATTACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" - """ - ) - try execute( - literal: """ - SELECT * FROM "sharing_grdb_cloudkit" - """ - ) - try installTriggers(db: self, cloudKitDatabase: cloudKitDatabase) - } -} - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) func installTriggers( db: Database, From dc09423c89ce49b223b681380de7d099ec8f0699 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 13 May 2025 17:16:38 -0500 Subject: [PATCH 012/581] wip --- Sources/SharingGRDBCore/CloudKit.swift | 1445 +++++++++++++----------- 1 file changed, 763 insertions(+), 682 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit.swift b/Sources/SharingGRDBCore/CloudKit.swift index fc5d1efd..d5fbd176 100644 --- a/Sources/SharingGRDBCore/CloudKit.swift +++ b/Sources/SharingGRDBCore/CloudKit.swift @@ -1,267 +1,284 @@ #if canImport(CloudKit) -import CloudKit -import Dependencies -import OSLog - -extension DependencyValues { - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - public var cloudKitDatabase: CloudKitDatabase { - get { self[CloudKitDatabase.self] } - set { self[CloudKitDatabase.self] = newValue } + import CloudKit + import Dependencies + import OSLog + + extension DependencyValues { + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + public var cloudKitDatabase: CloudKitDatabase { + get { self[CloudKitDatabase.self] } + set { self[CloudKitDatabase.self] = newValue } + } } -} -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension CloudKitDatabase: TestDependencyKey { - public static var testValue: CloudKitDatabase { - if shouldReportUnimplemented { - reportIssue("TODO") - } - return try! CloudKitDatabase( - container: CKContainer(identifier: "default"), - database: try! DatabaseQueue(), - tables: [] - ) + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CloudKitDatabase: TestDependencyKey { + public static var testValue: CloudKitDatabase { + if shouldReportUnimplemented { + reportIssue("TODO") + } + return try! CloudKitDatabase( + container: CKContainer(identifier: "default"), + database: try! DatabaseQueue(), + tables: [] + ) + } } -} -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -public actor CloudKitDatabase { - let container: CKContainer - let database: any DatabaseWriter - var syncEngine: CKSyncEngine! - var stateSerialization: CKSyncEngine.State.Serialization? - let tables: [any StructuredQueriesCore.Table.Type] - var delegate: Delegate - - public init( - container: CKContainer, - database: any DatabaseWriter, - tables: [any StructuredQueriesCore.Table.Type] - ) throws { - self.container = container - self.database = database - self.delegate = Delegate(container: container) - self.tables = tables - let stateSerializationData = - UserDefaults.standard.data( - forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) - ) ?? Data() - stateSerialization = try? JSONDecoder().decode( - CKSyncEngine.State.Serialization.self, - from: stateSerializationData - ) - let configuration = CKSyncEngine.Configuration( - database: container.privateCloudDatabase, - stateSerialization: stateSerialization, - delegate: delegate - ) - let syncEngine = CKSyncEngine(configuration) - self.syncEngine = syncEngine - delegate.syncEngine = syncEngine - try? FileManager.default - .createDirectory( - at: URL.applicationSupportDirectory, - withIntermediateDirectories: false + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + public actor CloudKitDatabase { + let container: CKContainer + let database: any DatabaseWriter + var syncEngine: CKSyncEngine! + var stateSerialization: CKSyncEngine.State.Serialization? + let tables: [any StructuredQueriesCore.Table.Type] + var delegate: Delegate + + public init( + container: CKContainer, + database: any DatabaseWriter, + tables: [any StructuredQueriesCore.Table.Type] + ) throws { + self.container = container + self.database = database + self.delegate = Delegate(container: container) + self.tables = tables + let stateSerializationData = + UserDefaults.standard.data( + forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) + ) ?? Data() + stateSerialization = try? JSONDecoder().decode( + CKSyncEngine.State.Serialization.self, + from: stateSerializationData ) - let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") - logger.info("open \(url.absoluteString)") - let cloudKitDatabase = try DatabasePool(path: url.absoluteString) - var migrator = DatabaseMigrator() - migrator.registerMigration("Create SharingGRDB tables") { db in - try SQLQueryExpression( - """ - CREATE TABLE "sharing_grdb_cloudkit" ( - "tableName" TEXT NOT NULL, - "primaryKey" TEXT NOT NULL, - "recordData" BLOB, - "userModificationDate" TEXT, - PRIMARY KEY("tableName", "primaryKey") - ) - """ + let configuration = CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: stateSerialization, + delegate: delegate ) - .execute(db) + let syncEngine = CKSyncEngine(configuration) + self.syncEngine = syncEngine + delegate.syncEngine = syncEngine + try? FileManager.default + .createDirectory( + at: URL.applicationSupportDirectory, + withIntermediateDirectories: false + ) + let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") + logger.info("open \(url.absoluteString)") + let cloudKitDatabase = try DatabasePool(path: url.absoluteString) + var migrator = DatabaseMigrator() + migrator.registerMigration("Create SharingGRDB tables") { db in + try SQLQueryExpression( + """ + CREATE TABLE "sharing_grdb_cloudkit" ( + "tableName" TEXT NOT NULL, + "primaryKey" TEXT NOT NULL, + "recordData" BLOB, + "userModificationDate" TEXT, + PRIMARY KEY("tableName", "primaryKey") + ) + """ + ) + .execute(db) + } + try migrator.migrate(cloudKitDatabase) + try database.write { db in + try db.execute( + literal: """ + ATTACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" + """ + ) + try createTriggers(db: db, cloudKitDatabase: self) + } + Self.saveZones(syncEngine: syncEngine, tables: tables) } - try migrator.migrate(cloudKitDatabase) - try database.write { db in - try db.execute( - literal: """ - ATTACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" - """ - ) - try db.execute( - literal: """ - SELECT * FROM "sharing_grdb_cloudkit" - """ - ) - try installTriggers(db: db, cloudKitDatabase: self) + + deinit { + print("?!?!?!") } - Self.saveZones(syncEngine: syncEngine, tables: tables) - } - deinit { - print("?!?!?!") - } + func tearDownSyncEngine() throws { + let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") + try database.write { db in + try dropTriggers(db: db, tables: tables) + try db.execute( + literal: """ + DETACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" + """ + ) + } + try? FileManager.default.removeItem(at: url) + } - func restartSyncEngine() { - UserDefaults.standard.removeObject( - forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) - ) - stateSerialization = nil - self.delegate = Delegate(container: container) - let configuration = CKSyncEngine.Configuration( - database: container.privateCloudDatabase, - stateSerialization: stateSerialization, - delegate: delegate - ) - syncEngine = CKSyncEngine(configuration) - delegate.syncEngine = syncEngine - saveZones() - } + func restartSyncEngine() throws { + try tearDownSyncEngine() + // setUpSyncEngine() - static func saveZones( - syncEngine: CKSyncEngine, - tables: [any StructuredQueriesCore.Table.Type] - ) { - syncEngine.state.add( - pendingDatabaseChanges: tables.map { - .saveZone(CKRecordZone(zoneName: $0.tableName)) - } - ) - } + // delete triggers + // delete all data from tables + // detach metadata database + // delete metadata database + // everything in initializer - func saveZones() { - Self.saveZones(syncEngine: syncEngine, tables: tables) - } + UserDefaults.standard.removeObject( + forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) + ) + stateSerialization = nil + self.delegate = Delegate(container: container) + let configuration = CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: stateSerialization, + delegate: delegate + ) + syncEngine = CKSyncEngine(configuration) + delegate.syncEngine = syncEngine + saveZones() + } - func didInsert(tableName: String, id: String) { - syncEngine.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: id, - zoneID: CKRecordZone(zoneName: tableName).zoneID - ) - ) - ] - ) - } + static func saveZones( + syncEngine: CKSyncEngine, + tables: [any StructuredQueriesCore.Table.Type] + ) { + syncEngine.state.add( + pendingDatabaseChanges: tables.map { + .saveZone(CKRecordZone(zoneName: $0.tableName)) + } + ) + } - func didUpdate(tableName: String, id: String) { - // TODO: perform modification date checks - syncEngine.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: id, - zoneID: CKRecordZone(zoneName: tableName).zoneID - ) - ) - ] - ) - } + func saveZones() { + Self.saveZones(syncEngine: syncEngine, tables: tables) + } - func willDelete(tableName: String, id: String) { - syncEngine.state.add( - pendingRecordZoneChanges: [ - .deleteRecord( - CKRecord.ID( - recordName: id, - zoneID: CKRecordZone(zoneName: tableName).zoneID + func didInsert(tableName: String, id: String) { + syncEngine.state.add( + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: id, + zoneID: CKRecordZone(zoneName: tableName).zoneID + ) ) - ) - ] - ) - } + ] + ) + } - #if DEBUG - public func deleteAllRecords() async throws { + func didUpdate(tableName: String, id: String) { + // TODO: perform modification date checks syncEngine.state.add( - pendingDatabaseChanges: tables.map { table in - .deleteZone(CKRecordZone.ID(zoneName: table.tableName)) - } + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: id, + zoneID: CKRecordZone(zoneName: tableName).zoneID + ) + ) + ] ) - try await syncEngine.sendChanges() } - #endif -} -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { - @Dependency(\.defaultDatabase) var database - let container: CKContainer - var syncEngine: CKSyncEngine! - init(container: CKContainer) { - self.container = container - } + func willDelete(tableName: String, id: String) { + syncEngine.state.add( + pendingRecordZoneChanges: [ + .deleteRecord( + CKRecord.ID( + recordName: id, + zoneID: CKRecordZone(zoneName: tableName).zoneID + ) + ) + ] + ) + } - func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - logger.info("CloudKitDatabase.Delegate.handleEvent.\(event)") - switch event { - case .stateUpdate(let stateUpdate): - withErrorReporting { - UserDefaults.standard.set( - try JSONEncoder().encode(stateUpdate.stateSerialization), - forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) + #if DEBUG + public func deleteAllRecords() async throws { + syncEngine.state.add( + pendingDatabaseChanges: tables.map { table in + .deleteZone(CKRecordZone.ID(zoneName: table.tableName)) + } ) + try await syncEngine.sendChanges() } - break - case .accountChange(_): - // TODO - break - case .fetchedDatabaseChanges(let changes): - handleFetchedDatabaseChanges(changes) - break - case .fetchedRecordZoneChanges(let changes): - handleFetchedRecordZoneChanges(changes) - break - case .sentDatabaseChanges(_): - // TODO - break - case .sentRecordZoneChanges(let changes): - handleSentRecordZoneChanges(changes) - break - case .willFetchChanges(_): - // TODO - break - case .willFetchRecordZoneChanges(_): - // TODO - break - case .didFetchRecordZoneChanges(_): - // TODO - break - case .didFetchChanges(_): - // TODO - break - case .willSendChanges(_): - // TODO - break - case .didSendChanges(_): - // TODO - break - @unknown default: - // TODO - break - } + #endif } - private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) { - var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() - var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() - defer { - syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) - syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { + @Dependency(\.defaultDatabase) var database + let container: CKContainer + var syncEngine: CKSyncEngine! + init(container: CKContainer) { + self.container = container } - withErrorReporting { - try database.write { db in - for savedRecord in changes.savedRecords { - try db.cacheNewRecordIfNewer(savedRecord) + func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + logger.info("CloudKitDatabase.Delegate.handleEvent.\(event)") + switch event { + case .stateUpdate(let stateUpdate): + withErrorReporting { + UserDefaults.standard.set( + try JSONEncoder().encode(stateUpdate.stateSerialization), + forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) + ) } + break + case .accountChange(_): + // TODO + break + case .fetchedDatabaseChanges(let changes): + handleFetchedDatabaseChanges(changes) + break + case .fetchedRecordZoneChanges(let changes): + handleFetchedRecordZoneChanges(changes) + break + case .sentDatabaseChanges(_): + // TODO + break + case .sentRecordZoneChanges(let changes): + handleSentRecordZoneChanges(changes) + break + case .willFetchChanges(_): + // TODO + break + case .willFetchRecordZoneChanges(_): + // TODO + break + case .didFetchRecordZoneChanges(_): + // TODO + break + case .didFetchChanges(_): + // TODO + break + case .willSendChanges(_): + // TODO + break + case .didSendChanges(_): + // TODO + break + @unknown default: + // TODO + break + } + } + + private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) { + var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() + var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() + defer { + syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + } + + withErrorReporting { + try database.write { db in + for savedRecord in changes.savedRecords { + try db.cacheNewRecordIfNewer(savedRecord) + } - for failedRecordSave in changes.failedRecordSaves { - // TODO: do this - switch failedRecordSave.error.code { + for failedRecordSave in changes.failedRecordSaves { + // TODO: do this + switch failedRecordSave.error.code { // case .internalError: // <#code#> // case .partialFailure: @@ -282,23 +299,23 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { // <#code#> // case .permissionFailure: // <#code#> - case .unknownItem: - print("") + case .unknownItem: + print("") // case .invalidArguments: // <#code#> // case .resultsTruncated: // <#code#> - case .serverRecordChanged: - guard let serverRecord = failedRecordSave.error.serverRecord - else { continue } - try db.cacheNewRecordIfNewer(serverRecord) - try serverRecord.upsertIfNewer(db: db) - print( - serverRecord.recordID, - failedRecordSave.record.recordID, - serverRecord.recordID == failedRecordSave.record.recordID - ) - newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) + case .serverRecordChanged: + guard let serverRecord = failedRecordSave.error.serverRecord + else { continue } + try db.cacheNewRecordIfNewer(serverRecord) + try serverRecord.upsertIfNewer(db: db) + print( + serverRecord.recordID, + failedRecordSave.record.recordID, + serverRecord.recordID == failedRecordSave.record.recordID + ) + newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) // case .serverRejectedRequest: // <#code#> // case .assetFileNotFound: @@ -321,11 +338,11 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { // <#code#> // case .quotaExceeded: // <#code#> - case .zoneNotFound: - // TODO: recreate zone if it matches a table name? - let zone = CKRecordZone(zoneID: failedRecordSave.record.recordID.zoneID) - newPendingDatabaseChanges.append(.saveZone(zone)) - newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) + case .zoneNotFound: + // TODO: recreate zone if it matches a table name? + let zone = CKRecordZone(zoneID: failedRecordSave.record.recordID.zoneID) + newPendingDatabaseChanges.append(.saveZone(zone)) + newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) // case .limitExceeded: // <#code#> @@ -348,522 +365,586 @@ final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { // case .accountTemporarilyUnavailable: // <#code#> - case .networkFailure, + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled: - print("") - default: - reportIssue("Unhandled error: \(failedRecordSave.error.code)") + print("") + default: + reportIssue("Unhandled error: \(failedRecordSave.error.code)") + } } - } - for (recordID, failedRecordDelete) in changes.failedRecordDeletes { - // TODO: do this - print(failedRecordDelete) - } + for (recordID, failedRecordDelete) in changes.failedRecordDeletes { + // TODO: do this + print(failedRecordDelete) + } - // TODO: double check this is correct. the sample code doesn't have this - for deletedRecordID in changes.deletedRecordIDs { - try deletedRecordID.delete(db: db) + // TODO: double check this is correct. the sample code doesn't have this + for deletedRecordID in changes.deletedRecordIDs { + try deletedRecordID.delete(db: db) + } } } } - } - private func handleFetchedRecordZoneChanges( - _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges - ) { - withErrorReporting { - try database.write { db in - for modification in changes.modifications { - try modification.record.upsertIfNewer(db: db) - try db.cacheNewRecordIfNewer(modification.record) - } + private func handleFetchedRecordZoneChanges( + _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges + ) { + withErrorReporting { + try database.write { db in + for modification in changes.modifications { + try modification.record.upsertIfNewer(db: db) + try db.cacheNewRecordIfNewer(modification.record) + } - for deletion in changes.deletions { - try deletion.recordID.delete(db: db) + for deletion in changes.deletions { + try deletion.recordID.delete(db: db) + } } } } - } - private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) { - withErrorReporting { - try database.write { db in - for deletion in changes.deletions { - let tableName = deletion.zoneID.zoneName - try SQLQueryExpression( - """ - DELETE FROM "\(raw: tableName)" - """ - ) - .execute(db) + private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) + { + withErrorReporting { + try database.write { db in + for deletion in changes.deletions { + let tableName = deletion.zoneID.zoneName + try SQLQueryExpression( + """ + DELETE FROM "\(raw: tableName)" + """ + ) + .execute(db) - syncEngine.state.add( - pendingDatabaseChanges: [ - .saveZone(CKRecordZone(zoneName: tableName)) - ] - ) + syncEngine.state.add( + pendingDatabaseChanges: [ + .saveZone(CKRecordZone(zoneName: tableName)) + ] + ) + } } } } - } - func nextRecordZoneChangeBatch( - _ context: CKSyncEngine.SendChangesContext, - syncEngine: CKSyncEngine - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - logger.info("CloudKitDatabase.Delegate.nextRecordZoneChangeBatch \(context)") - - let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) - let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in - do { - return try database.write { db in - let record = try db.fetchLastCachedRecord(id: recordID) - let row = try Row.fetchOne( - db, - SQLRequest( - sql: """ - SELECT * FROM "\(recordID.tableName)" WHERE "id" = ? - """, - arguments: [recordID.primaryKey] + func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + logger.info("CloudKitDatabase.Delegate.nextRecordZoneChangeBatch \(context)") + + let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) + let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in + do { + return try database.write { db in + let record = try db.fetchLastCachedRecord(id: recordID) + let row = try Row.fetchOne( + db, + SQLRequest( + sql: """ + SELECT * FROM "\(recordID.tableName)" WHERE "id" = ? + """, + arguments: [recordID.primaryKey] + ) ) - ) - guard let row - else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) - return nil + guard let row + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil + } + record.update(with: row) + try db.cacheNewRecordIfNewer(record) + return record } - record.update(with: row) - try db.cacheNewRecordIfNewer(record) - return record + } catch { + reportIssue(error) + return nil } - } catch { - reportIssue(error) - return nil } + return batch } - return batch } -} -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension CKRecord { - func update(with row: Row) { - for columnName in row.columnNames { - switch row[columnName]?.databaseValue.storage { - case .null: - if encryptedValues[columnName] != nil { - encryptedValues[columnName] = nil - } - case .int64(let value): - if object(forKey: columnName) as? Int64 != value { - encryptedValues[columnName] = value - } - case .double(let value): - if object(forKey: columnName) as? Double != value { - encryptedValues[columnName] = value - } - case .string(let value): - if object(forKey: columnName) as? String != value { - encryptedValues[columnName] = value - } - case .blob(let value): - if object(forKey: columnName) as? Data != value { - encryptedValues[columnName] = value + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKRecord { + func update(with row: Row) { + for columnName in row.columnNames { + switch row[columnName]?.databaseValue.storage { + case .null: + if encryptedValues[columnName] != nil { + encryptedValues[columnName] = nil + } + case .int64(let value): + if object(forKey: columnName) as? Int64 != value { + encryptedValues[columnName] = value + } + case .double(let value): + if object(forKey: columnName) as? Double != value { + encryptedValues[columnName] = value + } + case .string(let value): + if object(forKey: columnName) as? String != value { + encryptedValues[columnName] = value + } + case .blob(let value): + if object(forKey: columnName) as? Data != value { + encryptedValues[columnName] = value + } + case .none: + break } - case .none: - break } } } -} -extension CKRecord.ID { - fileprivate var primaryKey: String { recordName } - fileprivate var tableName: String { zoneID.zoneName } -} + extension CKRecord.ID { + fileprivate var primaryKey: String { recordName } + fileprivate var tableName: String { zoneID.zoneName } + } -private func stateSerializationKey(containerIdentifier: String?) -> String { - (containerIdentifier ?? "") + ".stateSerializationData" -} + private func stateSerializationKey(containerIdentifier: String?) -> String { + (containerIdentifier ?? "") + ".stateSerializationData" + } -extension Database { - func cacheNewRecordIfNewer(_ newRecord: CKRecord) throws { - let existingRecord = try fetchLastCachedRecord(id: newRecord.recordID) - if let existingRecordModificationDate = existingRecord.modificationDate { - if let newRecordModificationDate = newRecord.modificationDate, - existingRecordModificationDate < newRecordModificationDate - { - try update() + extension Database { + func cacheNewRecordIfNewer(_ newRecord: CKRecord) throws { + let existingRecord = try fetchLastCachedRecord(id: newRecord.recordID) + if let existingRecordModificationDate = existingRecord.modificationDate { + if let newRecordModificationDate = newRecord.modificationDate, + existingRecordModificationDate < newRecordModificationDate + { + try update() + } else { + print("Modification date caught") + } } else { + try update() + } + + func update() throws { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + newRecord.encodeSystemFields(with: archiver) + // TODO: should we use userModificationDate based on record.modificationDate? + try SQLQueryExpression( + """ + INSERT INTO "sharing_grdb_cloudkit" + ("tableName", "primaryKey", "recordData", "userModificationDate") + VALUES ( + \(bind: newRecord.recordID.tableName), + \(bind: newRecord.recordID.primaryKey), + \(archiver.encodedData), + \(bind: Date.ISO8601Representation(queryOutput: newRecord.modificationDate ?? Date())) + ) + ON CONFLICT("tableName", "primaryKey") DO UPDATE SET + "recordData" = \(archiver.encodedData) + """ + ) + .execute(self) + } + } + + func fetchLastCachedRecord(id recordID: CKRecord.ID) throws -> CKRecord { + return try SQLQueryExpression( + """ + SELECT "recordData" + FROM "sharing_grdb_cloudkit" + WHERE "tableName" = \(bind: recordID.tableName) + AND "primaryKey" = \(bind: recordID.primaryKey) + """, + as: Data?.self + ) + .fetchOne(self) + .flatMap { $0 } + .flatMap { data in + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = true + return CKRecord(coder: unarchiver) + } + ?? CKRecord(recordType: recordID.tableName, recordID: recordID) + } + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKRecord { + func upsertIfNewer(db: Database) throws { + let userModificationDate = + try SQLQueryExpression( + """ + SELECT "userModificationDate" FROM "sharing_grdb_cloudkit" + WHERE "tableName" = \(bind: recordID.tableName) + AND "primaryKey" = \(bind: recordID.primaryKey) + """, + as: Date?.ISO8601Representation.self + ) + .fetchOne(db) + ?? nil + + if let userModificationDate, + userModificationDate > (modificationDate ?? .distantPast) + { print("Modification date caught") + } else { + // TODO: can we use record.keysChanged to update only columns that changed? + let columnNames = try String.fetchAll( + db, + sql: """ + SELECT "name" + FROM pragma_table_info('\(recordID.tableName)') + """ + ) + var query: QueryFragment = """ + INSERT INTO "\(raw: recordID.tableName)" ( + """ + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) + query.append( + """ + ) VALUES ( + """ + ) + query.append( + columnNames.map { columnName in + "\(bind: convert(encryptedValues[columnName]))" + }.joined(separator: ",") + ) + query.append( + """ + ) ON CONFLICT("id") DO UPDATE SET + """ + ) + query.append( + columnNames + .map { " \(quote: $0) = excluded.\(quote: $0)" } + .joined(separator: ",") + ) + try SQLQueryExpression(query).execute(db) } - } else { - try update() } + } - func update() throws { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - newRecord.encodeSystemFields(with: archiver) - // TODO: should we use userModificationDate based on record.modificationDate? + extension CKRecord.ID { + func delete(db: Database) throws { try SQLQueryExpression( """ - INSERT INTO "sharing_grdb_cloudkit" - ("tableName", "primaryKey", "recordData", "userModificationDate") - VALUES ( - \(bind: newRecord.recordID.tableName), - \(bind: newRecord.recordID.primaryKey), - \(archiver.encodedData), - \(bind: Date.ISO8601Representation(queryOutput: newRecord.modificationDate ?? Date())) - ) - ON CONFLICT("tableName", "primaryKey") DO UPDATE SET - "recordData" = \(archiver.encodedData) + DELETE FROM "\(raw: tableName)" + WHERE "id" = \(bind: primaryKey) """ ) - .execute(self) + .execute(db) } } - func fetchLastCachedRecord(id recordID: CKRecord.ID) throws -> CKRecord { - return try SQLQueryExpression( - """ - SELECT "recordData" - FROM "sharing_grdb_cloudkit" - WHERE "tableName" = \(bind: recordID.tableName) - AND "primaryKey" = \(bind: recordID.primaryKey) - """, - as: Data?.self - ) - .fetchOne(self) - .flatMap { $0 } - .flatMap { data in - let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) - unarchiver.requiresSecureCoding = true - return CKRecord(coder: unarchiver) - } - ?? CKRecord(recordType: recordID.tableName, recordID: recordID) - } -} - -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension CKRecord { - func upsertIfNewer(db: Database) throws { - let userModificationDate = + extension CKRecordZone.ID { + func deleteAll(db: Database) throws { try SQLQueryExpression( """ - SELECT "userModificationDate" FROM "sharing_grdb_cloudkit" - WHERE "tableName" = \(bind: recordID.tableName) - AND "primaryKey" = \(bind: recordID.primaryKey) - """, - as: Date?.ISO8601Representation.self + DELETE FROM "\(raw: zoneName)" + """ ) - .fetchOne(db) - ?? nil + .execute(db) + } + } - if let userModificationDate, - userModificationDate > (modificationDate ?? .distantPast) - { - print("Modification date caught") + private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression { + guard let value else { + // TODO: better way? + return SQLQueryExpression("NULL", as: Void?.self) + } + if let value = value as? Int64 { + return value + } else if let value = value as? Double { + return value + } else if let value = value as? String { + return value + } else if let value = value as? Data { + return value } else { - // TODO: can we use record.keysChanged to update only columns that changed? - let columnNames = try String.fetchAll( - db, - sql: """ - SELECT "name" - FROM pragma_table_info('\(recordID.tableName)') - """ - ) - var query: QueryFragment = """ - INSERT INTO "\(raw: recordID.tableName)" ( - """ - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) - query.append( + fatalError("TODO: do we need to do all numeric types?") + } + } + + extension DatabaseFunction { + convenience init(name: String, function: @escaping @Sendable (String, String) async -> Void) { + self.init(name, argumentCount: 2) { arguments in + guard + let tableName = String.fromDatabaseValue(arguments[0]), + let id = String.fromDatabaseValue(arguments[1]) + else { + return 0 + } + Task { await function(tableName, id) } + return 0 + } + } + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + func dropTriggers( + db: Database, + tables: [any StructuredQueriesCore.Table.Type] + ) throws { + db.remove(function: .didInsert) + db.remove(function: .didUpdate) + db.remove(function: .willDelete) + for table in tables { + try SQLQueryExpression( """ - ) VALUES ( + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" """ ) - query.append( - columnNames.map { columnName in - "\(bind: convert(encryptedValues[columnName]))" - }.joined(separator: ",") + .execute(db) + let foreignKeys = try SQLQueryExpression( + """ + SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) + """, + as: PragmaForeignKey.self ) - query.append( + .fetchAll(db) + for foreignKey in foreignKeys { + switch foreignKey.onDelete { + case .cascade: + try SQLQueryExpression( + """ + DROP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + """ + ) + .execute(db) + case .restrict: + fatalError("TODO: report issue?") + case .setDefault: + fatalError("TODO: report issue?") + case .setNull: + try SQLQueryExpression( + """ + DROP TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + """ + ) + .execute(db) + case .noAction: + continue + } + + switch foreignKey.onUpdate { + case .cascade: + fatalError("TODO") + case .restrict: + fatalError("TODO") + case .setDefault: + fatalError("TODO") + case .setNull: + fatalError("TODO") + case .noAction: + continue + } + } + } + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + func createTriggers( + db: Database, + cloudKitDatabase: CloudKitDatabase + ) throws { + db.add(function: .didInsert) + db.add(function: .didUpdate) + db.add(function: .willDelete) + for table in cloudKitDatabase.tables { + try Trigger.delete(tableName: table.tableName).sql + .execute(db) + try Trigger.insert(tableName: table.tableName).sql + .execute(db) + try Trigger.update(tableName: table.tableName).sql + .execute(db) + try SQLQueryExpression( """ - ) ON CONFLICT("id") DO UPDATE SET + CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" + AFTER UPDATE ON \(table) FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit" + ("tableName", "primaryKey", "userModificationDate") + VALUES + ( + '\(raw: table.tableName)', + new."id", + datetime('subsec') + ) + ON CONFLICT("tableName", "primaryKey") DO UPDATE SET + "userModificationDate" = excluded."userModificationDate"; + END """ ) - query.append( - columnNames - .map { " \(quote: $0) = excluded.\(quote: $0)" } - .joined(separator: ",") + .execute(db) + let foreignKeys = try SQLQueryExpression( + """ + SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) + """, + as: PragmaForeignKey.self ) - try SQLQueryExpression(query).execute(db) + .fetchAll(db) + for foreignKey in foreignKeys { + switch foreignKey.onDelete { + case .cascade: + try SQLQueryExpression( + """ + CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + DELETE FROM \(quote: table.tableName) + WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); + END + """ + ) + .execute(db) + case .restrict: + fatalError("TODO: report issue?") + case .setDefault: + fatalError("TODO: report issue?") + case .setNull: + try SQLQueryExpression( + """ + CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(quote: table.tableName) + SET \(quote: foreignKey.from) = NULL + WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); + END + """ + ) + .execute(db) + case .noAction: + continue + } + + switch foreignKey.onUpdate { + case .cascade: + fatalError("TODO") + case .restrict: + fatalError("TODO") + case .setDefault: + fatalError("TODO") + case .setNull: + fatalError("TODO") + case .noAction: + continue + } + } } } -} -extension CKRecord.ID { - func delete(db: Database) throws { - try SQLQueryExpression( - """ - DELETE FROM "\(raw: tableName)" - WHERE "id" = \(bind: primaryKey) - """ - ) - .execute(db) - } -} + private struct PragmaForeignKey: QueryDecodable, QueryRepresentable { + enum Action: String, QueryBindable { + case cascade = "CASCADE" + case restrict = "RESTRICT" + case setDefault = "SET DEFAULT" + case setNull = "SET NULL" + case noAction = "NO ACTION" + } -extension CKRecordZone.ID { - func deleteAll(db: Database) throws { - try SQLQueryExpression( - """ - DELETE FROM "\(raw: zoneName)" - """ - ) - .execute(db) - } -} + typealias QueryValue = Self -private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression { - guard let value else { - // TODO: better way? - return SQLQueryExpression("NULL", as: Void?.self) - } - if let value = value as? Int64 { - return value - } else if let value = value as? Double { - return value - } else if let value = value as? String { - return value - } else if let value = value as? Data { - return value - } else { - fatalError("TODO: do we need to do all numeric types?") - } -} + let table: String + let from: String + let to: String + let onUpdate: Action + let onDelete: Action -extension DatabaseFunction { - convenience init(name: String, function: @escaping @Sendable (String, String) async -> Void) { - self.init(name, argumentCount: 2) { arguments in + init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { guard - let tableName = String.fromDatabaseValue(arguments[0]), - let id = String.fromDatabaseValue(arguments[1]) + let table = try decoder.decode(String.self), + let from = try decoder.decode(String.self), + let to = try decoder.decode(String.self), + let onUpdate = try decoder.decode(Action.self), + let onDelete = try decoder.decode(Action.self) else { - return 0 + throw QueryDecodingError.missingRequiredColumn } - Task { await function(tableName, id) } - return 0 + self.table = table + self.from = from + self.to = to + self.onUpdate = onUpdate + self.onDelete = onDelete } - } -} -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -func installTriggers( - db: Database, - cloudKitDatabase: CloudKitDatabase -) throws { - db.add( - function: DatabaseFunction( - name: "didInsert", - function: { await cloudKitDatabase.didInsert(tableName: $0, id: $1) } - ) - ) - db.add( - function: DatabaseFunction( - name: "didUpdate", - function: { await cloudKitDatabase.didUpdate(tableName: $0, id: $1) } - ) - ) - db.add( - function: DatabaseFunction( - name: "willDelete", - function: { await cloudKitDatabase.willDelete(tableName: $0, id: $1) } - ) - ) - db.add( - function: DatabaseFunction( - "currentDate", - argumentCount: 0, - function: { _ in - Date() - } - ) - ) - for table in cloudKitDatabase.tables { - try Trigger.delete(tableName: table.tableName).sql - .execute(db) - try Trigger.insert(tableName: table.tableName).sql - .execute(db) - try Trigger.update(tableName: table.tableName).sql - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" - AFTER UPDATE ON \(table) FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit" - ("tableName", "primaryKey", "userModificationDate") - VALUES - ( - '\(raw: table.tableName)', - new."id", - currentDate() - ) - ON CONFLICT("tableName", "primaryKey") DO UPDATE SET - "userModificationDate" = excluded."userModificationDate"; - END + static var columns: QueryFragment { """ - ) - .execute(db) - let foreignKeys = try SQLQueryExpression( + "table", "from", "to", "on_update", "on_delete", "match" """ - SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) - """, - as: PragmaForeignKey.self - ) - .fetchAll(db) - for foreignKey in foreignKeys { - switch foreignKey.onDelete { - case .cascade: - try SQLQueryExpression( - """ - CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - DELETE FROM \(quote: table.tableName) - WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); - END - """ - ) - .execute(db) - case .restrict: - fatalError("TODO: report issue?") - case .setDefault: - fatalError("TODO: report issue?") - case .setNull: - try SQLQueryExpression( - """ - CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(quote: table.tableName) - SET \(quote: foreignKey.from) = NULL - WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); - END - """ - ) - .execute(db) - case .noAction: - continue - } - - switch foreignKey.onUpdate { - case .cascade: - fatalError("TODO") - case .restrict: - fatalError("TODO") - case .setDefault: - fatalError("TODO") - case .setNull: - fatalError("TODO") - case .noAction: - continue - } } } -} - -private struct PragmaForeignKey: QueryDecodable, QueryRepresentable { - enum Action: String, QueryBindable { - case cascade = "CASCADE" - case restrict = "RESTRICT" - case setDefault = "SET DEFAULT" - case setNull = "SET NULL" - case noAction = "NO ACTION" - } - typealias QueryValue = Self - - let table: String - let from: String - let to: String - let onUpdate: Action - let onDelete: Action - - init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - guard - let table = try decoder.decode(String.self), - let from = try decoder.decode(String.self), - let to = try decoder.decode(String.self), - let onUpdate = try decoder.decode(Action.self), - let onDelete = try decoder.decode(Action.self) - else { - throw QueryDecodingError.missingRequiredColumn - } - self.table = table - self.from = from - self.to = to - self.onUpdate = onUpdate - self.onDelete = onDelete + struct Trigger { + let idColumn: String + let function: String + let tableName: String + let type: String + let when: String + static func delete(tableName: String) -> Self { + Trigger( + idColumn: "old.id", + function: "willDelete", + tableName: tableName, + type: "DELETE", + when: "BEFORE" + ) + } + static func insert(tableName: String) -> Self { + Trigger( + idColumn: "new.id", + function: "didInsert", + tableName: tableName, + type: "INSERT", + when: "AFTER" + ) + } + static func update(tableName: String) -> Self { + Trigger( + idColumn: "new.id", + function: "didUpdate", + tableName: tableName, + type: "UPDATE", + when: "AFTER" + ) + } + var sql: SQLQueryExpression { + SQLQueryExpression( + """ + CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" + \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN + SELECT \(raw: function)('\(raw: tableName)', \(raw: idColumn)); + END + """ + ) + } } - static var columns: QueryFragment { - """ - "table", "from", "to", "on_update", "on_delete", "match" - """ - } -} + @available(macOS 11, iOS 14, watchOS 7, tvOS 14, *) + private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") +#endif -struct Trigger { - let idColumn: String - let function: String - let tableName: String - let type: String - let when: String - static func delete(tableName: String) -> Self { - Trigger( - idColumn: "old.id", - function: "willDelete", - tableName: tableName, - type: "DELETE", - when: "BEFORE" - ) - } - static func insert(tableName: String) -> Self { - Trigger( - idColumn: "new.id", - function: "didInsert", - tableName: tableName, - type: "INSERT", - when: "AFTER" +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +extension DatabaseFunction { + static var didInsert: Self { + @Dependency(\.cloudKitDatabase) var cloudKitDatabase + return Self( + name: "didInsert", + function: { await cloudKitDatabase.didInsert(tableName: $0, id: $1) } ) } - static func update(tableName: String) -> Self { - Trigger( - idColumn: "new.id", - function: "didUpdate", - tableName: tableName, - type: "UPDATE", - when: "AFTER" + static var didUpdate: Self { + @Dependency(\.cloudKitDatabase) var cloudKitDatabase + return Self( + name: "didUpdate", + function: { await cloudKitDatabase.didUpdate(tableName: $0, id: $1) } ) } - var sql: SQLQueryExpression { - SQLQueryExpression( - """ - CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" - \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN - SELECT \(raw: function)('\(raw: tableName)', \(raw: idColumn)); - END - """ + static var willDelete: Self { + @Dependency(\.cloudKitDatabase) var cloudKitDatabase + return Self( + name: "willDelete", + function: { await cloudKitDatabase.willDelete(tableName: $0, id: $1) } ) } } - -@available(macOS 11, iOS 14, watchOS 7, tvOS 14, *) -private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") -#endif From 9465a7dd876d2395d9fa6df0772ca6b3aa076fcf Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 14 May 2025 09:04:55 -0700 Subject: [PATCH 013/581] wip --- Sources/SharingGRDBCore/CloudKit2.swift | 173 ++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 Sources/SharingGRDBCore/CloudKit2.swift diff --git a/Sources/SharingGRDBCore/CloudKit2.swift b/Sources/SharingGRDBCore/CloudKit2.swift new file mode 100644 index 00000000..7004c153 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit2.swift @@ -0,0 +1,173 @@ +import CloudKit +import OSLog + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +public final actor SyncEngine { + nonisolated let container: CKContainer + nonisolated let database: any DatabaseWriter + nonisolated let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + lazy var stateSerialization: CKSyncEngine.State.Serialization? = + withErrorReporting { + if let data = try? Data(contentsOf: .stateSerialization(container: container)) { + return try JSONDecoder().decode(CKSyncEngine.State.Serialization?.self, from: data) + } else { + return nil + } + } ?? nil + { + didSet { + withErrorReporting { + if let stateSerialization { + try JSONEncoder() + .encode(stateSerialization) + .write(to: .stateSerialization(container: container)) + } else { + try FileManager.default.removeItem(at: .stateSerialization(container: container)) + } + } + } + } + lazy var underlyingSyncEngine: CKSyncEngine = defaultSyncEngine + + public init( + container: CKContainer, + database: any DatabaseWriter, + tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + ) { + self.container = container + self.database = database + self.tables = tables + Task { _ = await underlyingSyncEngine } + } + + func deleteLocalData() { + withErrorReporting { + try database.write { db in + for table in tables { + db.deleteAll(tableName: table.tableName) + } + } + } + stateSerialization = nil + underlyingSyncEngine = defaultSyncEngine + } + + private var defaultSyncEngine: CKSyncEngine { + CKSyncEngine( + CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: stateSerialization, + delegate: self + ) + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine: CKSyncEngineDelegate { + public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + switch event { + case .accountChange(let event): + handleAccountChange(event) + case .stateUpdate(let event): + stateSerialization = event.stateSerialization + case .fetchedDatabaseChanges(let event): + handleFetchedDatabaseChanges(event) + case .sentDatabaseChanges: + break + case .fetchedRecordZoneChanges(let event): + handleFetchedRecordZoneChanges(event) + case .sentRecordZoneChanges(let event): + handleSentRecordZoneChanges(event) + case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, + .didFetchChanges, .willSendChanges, .didSendChanges: + break + @unknown default: + logger.warning("Sync engine received unknown event: \(event)") + } + } + + public func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + nil + } + + private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { + switch event.changeType { + case .signIn: + for table in tables { + underlyingSyncEngine.state.add( + pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneName: table.tableName))] + ) + withErrorReporting { + let names: [String] = try database.read { db in + func open(_ table: T.Type) throws -> [String] { + try SQLQueryExpression( + "SELECT \(table.columns.primaryKey) FROM \(table)", + as: String.self + ) + .fetchAll(db) + } + return try open(table) + } + underlyingSyncEngine.state.add( + pendingRecordZoneChanges: names.map { + .saveRecord( + CKRecord.ID( + recordName: $0, + zoneID: CKRecordZone(zoneName: table.tableName).zoneID + ) + ) + } + ) + } + } + case .signOut, .switchAccounts: + deleteLocalData() + @unknown default: + break + } + } + + private func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { + withErrorReporting { + try database.write { db in + for deletion in event.deletions { + db.deleteAll(tableName: deletion.zoneID.zoneName) + } + } + } + } + + private func handleFetchedRecordZoneChanges( + _ event: CKSyncEngine.Event.FetchedRecordZoneChanges + ) { + } + + private func handleSentRecordZoneChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) { + } +} + +extension Database { + fileprivate func deleteAll(tableName: String) { + withErrorReporting { + try SQLQueryExpression("DELETE FROM \(quote: tableName)").execute(self) + } + } +} + +@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) +extension URL { + fileprivate static func stateSerialization(container: CKContainer) throws -> Self { + try FileManager.default + .createDirectory(at: applicationSupportDirectory, withIntermediateDirectories: true) + return applicationSupportDirectory.appending( + component: "sharing-grdb-icloud\(container.containerIdentifier.map { ".\($0)" } ?? "").json" + ) + } +} + +@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) +private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") From bcaf2192af884f2a2b36a6cd4fa5a69e6f88c175 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 14 May 2025 11:43:20 -0500 Subject: [PATCH 014/581] wip --- Examples/Reminders/RemindersApp.swift | 34 ++-- Examples/Reminders/RemindersLists.swift | 52 +++++- Examples/Reminders/Schema.swift | 225 ++++++++++++------------ Sources/SharingGRDBCore/CloudKit.swift | 8 +- 4 files changed, 181 insertions(+), 138 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index d81f66c7..7369b353 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -4,26 +4,32 @@ import SwiftUI @main struct RemindersApp: App { + @Dependency(\.context) var context + init() { - try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - $0.cloudKitDatabase = try CloudKitDatabase( - container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), - database: $0.defaultDatabase, - tables: [ - Reminder.self, - RemindersList.self, - Tag.self, - ReminderTag.self, - ] - ) + if context == .live { + try! prepareDependencies { + $0.defaultDatabase = try Reminders.appDatabase() + $0.cloudKitDatabase = try CloudKitDatabase( + container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), + database: $0.defaultDatabase, + tables: [ + Reminder.self, + RemindersList.self, + Tag.self, + ReminderTag.self, + ] + ) + } } } var body: some Scene { WindowGroup { - NavigationStack { - RemindersListsView() + if context == .live { + NavigationStack { + RemindersListsView() + } } } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 2a9eef3b..2d6213cf 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -173,15 +173,55 @@ struct RemindersListsView: View { .listStyle(.insetGrouped) .toolbar { #if DEBUG - ToolbarItem(placement: .destructiveAction) { - Button("Clear data") { - Task { - await withErrorReporting { - try await cloudKitDatabase.deleteAllRecords() + ToolbarItem(placement: .primaryAction) { + Menu { + Button { + Task { + await withErrorReporting { + try await cloudKitDatabase.deleteAllRecords() + } + } + } label: { + Text("Clear data") + Image(systemName: "xmark") } + Button { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } + } + } label: { + Text("Seed data") + Image(systemName: "leaf") + } + // Group { + // Menu { + // ForEach(Ordering.allCases, id: \.self) { ordering in + // Button { + // self.ordering = ordering + // } label: { + // Text(ordering.rawValue) + // ordering.icon + // } + // } + // } label: { + // Text("Sort By") + // Text(ordering.rawValue) + // Image(systemName: "arrow.up.arrow.down") + // } + // Button { + // showCompleted.toggle() + // } label: { + // Text(showCompleted ? "Hide Completed" : "Show Completed") + // Image(systemName: showCompleted ? "eye.slash.fill" : "eye") + // } + // } + // .tint(detailType.color) + } label: { + Image(systemName: "ellipsis.circle") } } - } #endif ToolbarItem(placement: .bottomBar) { HStack { diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 043ccd64..09e0dd46 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -136,7 +136,7 @@ func appDatabase() throws -> any DatabaseWriter { "priority" INTEGER, "remindersListID" TEXT NOT NULL, "title" TEXT NOT NULL, - + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ @@ -157,7 +157,7 @@ func appDatabase() throws -> any DatabaseWriter { "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "reminderID" TEXT NOT NULL, "tagID" TEXT NOT NULL, - + FOREIGN KEY("reminderID") REFERENCES "reminders"("id") ON DELETE CASCADE, FOREIGN KEY("tagID") REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT @@ -242,121 +242,118 @@ let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { func seedSampleData() throws { - // TODO: add a dedicated seed button - return () - - try seed { - RemindersList( - id: UUID(1), - color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), - title: "Personal" - ) - RemindersList( - id: UUID(2), - color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), - title: "Family" - ) - RemindersList( - id: UUID(3), - color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), - title: "Business" - ) + try seed { + RemindersList( + id: UUID(1), + color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), + title: "Personal" + ) + RemindersList( + id: UUID(2), + color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), + title: "Family" + ) + RemindersList( + id: UUID(3), + color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), + title: "Business" + ) - Reminder( - id: UUID(1), - notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - remindersListID: UUID(1), - title: "Groceries" - ) - Reminder( - id: UUID(2), - dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), - isFlagged: true, - remindersListID: UUID(1), - title: "Haircut" - ) - Reminder( - id: UUID(3), - dueDate: Date(), - notes: "Ask about diet", - priority: .high, - remindersListID: UUID(1), - title: "Doctor appointment" - ) - Reminder( - id: UUID(4), - dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), - isCompleted: true, - remindersListID: UUID(1), - title: "Take a walk" - ) - Reminder( - id: UUID(5), - dueDate: Date(), - remindersListID: UUID(1), - title: "Buy concert tickets" - ) - Reminder( - id: UUID(6), - dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), - isFlagged: true, - priority: .high, - remindersListID: UUID(2), - title: "Pick up kids from school" - ) - Reminder( - id: UUID(7), - dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, - priority: .low, - remindersListID: UUID(2), - title: "Get laundry" - ) - Reminder( - id: UUID(8), - dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), - isCompleted: false, - priority: .high, - remindersListID: UUID(2), - title: "Take out trash" - ) - Reminder( - id: UUID(9), - dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), - notes: """ - Status of tax return - Expenses for next year - Changing payroll company - """, - remindersListID: UUID(3), - title: "Call accountant" - ) - Reminder( - id: UUID(10), - dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, - priority: .medium, - remindersListID: UUID(3), - title: "Send weekly emails" - ) + Reminder( + id: UUID(1), + notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", + remindersListID: UUID(1), + title: "Groceries" + ) + Reminder( + id: UUID(2), + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), + isFlagged: true, + remindersListID: UUID(1), + title: "Haircut" + ) + Reminder( + id: UUID(3), + dueDate: Date(), + notes: "Ask about diet", + priority: .high, + remindersListID: UUID(1), + title: "Doctor appointment" + ) + Reminder( + id: UUID(4), + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), + isCompleted: true, + remindersListID: UUID(1), + title: "Take a walk" + ) + Reminder( + id: UUID(5), + dueDate: Date(), + remindersListID: UUID(1), + title: "Buy concert tickets" + ) + Reminder( + id: UUID(6), + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), + isFlagged: true, + priority: .high, + remindersListID: UUID(2), + title: "Pick up kids from school" + ) + Reminder( + id: UUID(7), + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + priority: .low, + remindersListID: UUID(2), + title: "Get laundry" + ) + Reminder( + id: UUID(8), + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), + isCompleted: false, + priority: .high, + remindersListID: UUID(2), + title: "Take out trash" + ) + Reminder( + id: UUID(9), + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), + notes: """ + Status of tax return + Expenses for next year + Changing payroll company + """, + remindersListID: UUID(3), + title: "Call accountant" + ) + Reminder( + id: UUID(10), + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + priority: .medium, + remindersListID: UUID(3), + title: "Send weekly emails" + ) - Tag(id: UUID(1), title: "car") - Tag(id: UUID(2), title: "kids") - Tag(id: UUID(3), title: "someday") - Tag(id: UUID(4), title: "optional") - Tag(id: UUID(5), title: "social") - Tag(id: UUID(6), title: "night") - Tag(id: UUID(7), title: "adulting") + Tag(id: UUID(1), title: "car") + Tag(id: UUID(2), title: "kids") + Tag(id: UUID(3), title: "someday") + Tag(id: UUID(4), title: "optional") + Tag(id: UUID(5), title: "social") + Tag(id: UUID(6), title: "night") + Tag(id: UUID(7), title: "adulting") - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(3)) - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(4)) - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(7)) - ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(3)) - ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(4)) - ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(7)) - ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(1)) - ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(2)) - } + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(3)) + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(4)) + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(7)) + ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(3)) + ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(4)) + ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(7)) + ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(1)) + ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(2)) + } } } #endif diff --git a/Sources/SharingGRDBCore/CloudKit.swift b/Sources/SharingGRDBCore/CloudKit.swift index d5fbd176..0aab9c8e 100644 --- a/Sources/SharingGRDBCore/CloudKit.swift +++ b/Sources/SharingGRDBCore/CloudKit.swift @@ -31,13 +31,13 @@ let database: any DatabaseWriter var syncEngine: CKSyncEngine! var stateSerialization: CKSyncEngine.State.Serialization? - let tables: [any StructuredQueriesCore.Table.Type] + let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] var delegate: Delegate public init( container: CKContainer, database: any DatabaseWriter, - tables: [any StructuredQueriesCore.Table.Type] + tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) throws { self.container = container self.database = database @@ -138,7 +138,7 @@ static func saveZones( syncEngine: CKSyncEngine, - tables: [any StructuredQueriesCore.Table.Type] + tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { syncEngine.state.add( pendingDatabaseChanges: tables.map { @@ -684,7 +684,7 @@ @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) func dropTriggers( db: Database, - tables: [any StructuredQueriesCore.Table.Type] + tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) throws { db.remove(function: .didInsert) db.remove(function: .didUpdate) From 0cd9534ee6cce5671447ccc34b0db6af124c8b68 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 14 May 2025 15:58:11 -0500 Subject: [PATCH 015/581] wip --- Package.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index c851091e..f4bb480c 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), //.package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.2.0"), - .package(path: "../swift-structured-queries") + .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "default-date-uuid-representations"), ], targets: [ .target( diff --git a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9ba12357..9921ede0 100644 --- a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cb356621f2478d22ec989416bb5cdd7d9e8c09843c438a8f0f1244eede1c12ef", + "originHash" : "9c071e96c29584bec134f4f8a5f344125ef801fc238a2c7f9a67c8eb135180d9", "pins" : [ { "identity" : "combine-schedulers", @@ -136,6 +136,15 @@ "version" : "1.18.3" } }, + { + "identity" : "swift-structured-queries", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-structured-queries", + "state" : { + "branch" : "default-date-uuid-representations", + "revision" : "97adefc550df097caff1b5de30bd6add6b9fba69" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", From 72c61ba057e31732b3bf8dbb62a9c8f5ab868506 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 14 May 2025 15:07:19 -0700 Subject: [PATCH 016/581] wip --- Examples/Reminders/RemindersApp.swift | 12 +- Sources/SharingGRDBCore/CloudKit2.swift | 228 +++++++++++++++++++----- 2 files changed, 198 insertions(+), 42 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 7369b353..c8e24fac 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -10,7 +10,17 @@ struct RemindersApp: App { if context == .live { try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() - $0.cloudKitDatabase = try CloudKitDatabase( +// $0.cloudKitDatabase = try CloudKitDatabase( +// container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), +// database: $0.defaultDatabase, +// tables: [ +// Reminder.self, +// RemindersList.self, +// Tag.self, +// ReminderTag.self, +// ] +// ) + $0.defaultSyncEngine = SyncEngine( container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), database: $0.defaultDatabase, tables: [ diff --git a/Sources/SharingGRDBCore/CloudKit2.swift b/Sources/SharingGRDBCore/CloudKit2.swift index 7004c153..1fc11c03 100644 --- a/Sources/SharingGRDBCore/CloudKit2.swift +++ b/Sources/SharingGRDBCore/CloudKit2.swift @@ -1,32 +1,20 @@ import CloudKit import OSLog +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension DependencyValues { + public var defaultSyncEngine: SyncEngine { + get { self[SyncEngine.self] } + set { self[SyncEngine.self] = newValue } + } +} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final actor SyncEngine { nonisolated let container: CKContainer nonisolated let database: any DatabaseWriter - nonisolated let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - lazy var stateSerialization: CKSyncEngine.State.Serialization? = - withErrorReporting { - if let data = try? Data(contentsOf: .stateSerialization(container: container)) { - return try JSONDecoder().decode(CKSyncEngine.State.Serialization?.self, from: data) - } else { - return nil - } - } ?? nil - { - didSet { - withErrorReporting { - if let stateSerialization { - try JSONEncoder() - .encode(stateSerialization) - .write(to: .stateSerialization(container: container)) - } else { - try FileManager.default.removeItem(at: .stateSerialization(container: container)) - } - } - } - } + nonisolated let tables: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + var stateSerialization: CKSyncEngine.State.Serialization? lazy var underlyingSyncEngine: CKSyncEngine = defaultSyncEngine public init( @@ -36,20 +24,146 @@ public final actor SyncEngine { ) { self.container = container self.database = database - self.tables = tables - Task { _ = await underlyingSyncEngine } + self.tables = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) + Task { + await withErrorReporting("SharingGRDB CloudKit Failure") { + try await setUpSyncEngine() + } + } + } + + func setUpSyncEngine() throws { + let metadatabaseURL = try URL.metadatabase(container: container) + var configuration = Configuration() + configuration.prepareDatabase { db in + db.trace { + logger.debug("\($0.expandedDescription)") + } + } + let metadatabase = try DatabaseQueue( + path: metadatabaseURL.absoluteString, + configuration: configuration + ) + logger.info( + """ + Opened metadatabase + \(metadatabaseURL.absoluteString) + """ + ) + var migrator = DatabaseMigrator() + #if DEBUG + migrator.eraseDatabaseOnSchemaChange = true + #endif + migrator.registerMigration("Create Metadata Tables") { db in + try SQLQueryExpression( + """ + CREATE TABLE "records" ( + "zoneName" TEXT NOT NULL, + "recordName" TEXT NOT NULL, + "recordData" BLOB, + "userModificationDate" TEXT, + PRIMARY KEY("zoneName", "recordName") + ) STRICT + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TABLE "zones" ( + "zoneName" TEXT PRIMARY KEY NOT NULL + "columnNames" TEXT + ) STRICT + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TABLE "stateSerialization" ( + "id" INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK ("id" = 1), + "data" TEXT + ) STRICT + """ + ) + .execute(db) + } + try migrator.migrate(metadatabase) + withErrorReporting { + stateSerialization = + try metadatabase.read { db in + try SQLQueryExpression( + """ + SELECT "data" FROM "stateSerialization" LIMIT 1 + """, + as: CKSyncEngine.State.Serialization?.JSONRepresentation.self + ) + .fetchOne(db) + } + ?? nil + } + let existingTables = try metadatabase.read { db in + try SQLQueryExpression( + """ + SELECT "zoneName" FROM "zones" + """, + as: String.self + ) + .fetchAll(db) + } + let newTables = Set(tables.keys).subtracting(existingTables) + if !newTables.isEmpty { + Task { + await withErrorReporting { + try await underlyingSyncEngine.fetchChanges( + CKSyncEngine.FetchChangesOptions( + scope: .zoneIDs(newTables.map { CKRecordZone(zoneName: $0).zoneID }) + ) + ) + try await metadatabase.write { db in + try SQLQueryExpression( + """ + INSERT INTO "zones" ("zoneName") + VALUES \(newTables.map { QueryFragment("(\(bind: $0))") }.joined(separator: ", ")) + """ + ) + .execute(db) + } + } + } + } + try database.write { db in + try SQLQueryExpression( + "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitDatabaseName)" + ) + .execute(db) + } } - func deleteLocalData() { + func tearDownSyncEngine() throws { + let metadatabaseURL = try URL.metadatabase(container: container) + try database.write { db in + try SQLQueryExpression( + "DETACH DATABASE \(quote: .sharingGRDBCloudKitDatabaseName)" + ) + .execute(db) + } + try FileManager.default.removeItem(at: metadatabaseURL) + } + + func deleteLocalData() throws { withErrorReporting { try database.write { db in - for table in tables { - db.deleteAll(tableName: table.tableName) + for table in tables.values { + func open(_: T.Type) { + withErrorReporting { + try T.delete().execute(db) + } + } + open(table) } } } - stateSerialization = nil - underlyingSyncEngine = defaultSyncEngine + try tearDownSyncEngine() + try setUpSyncEngine() } private var defaultSyncEngine: CKSyncEngine { @@ -71,6 +185,24 @@ extension SyncEngine: CKSyncEngineDelegate { handleAccountChange(event) case .stateUpdate(let event): stateSerialization = event.stateSerialization + withErrorReporting { + try database.write { db in + let data = BindQueryExpression( + event.stateSerialization, + as: CKSyncEngine.State.Serialization.JSONRepresentation.self + ) + try SQLQueryExpression( + """ + INSERT INTO \(quote: .sharingGRDBCloudKitDatabaseName)."stateSerialization" + ("id", "data") + VALUES + (1, \(data)) + """ + ) + .execute(db) + } + } + case .fetchedDatabaseChanges(let event): handleFetchedDatabaseChanges(event) case .sentDatabaseChanges: @@ -97,7 +229,7 @@ extension SyncEngine: CKSyncEngineDelegate { private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { switch event.changeType { case .signIn: - for table in tables { + for table in tables.values { underlyingSyncEngine.state.add( pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneName: table.tableName))] ) @@ -125,7 +257,9 @@ extension SyncEngine: CKSyncEngineDelegate { } } case .signOut, .switchAccounts: - deleteLocalData() + withErrorReporting { + try deleteLocalData() + } @unknown default: break } @@ -135,7 +269,14 @@ extension SyncEngine: CKSyncEngineDelegate { withErrorReporting { try database.write { db in for deletion in event.deletions { - db.deleteAll(tableName: deletion.zoneID.zoneName) + if let table = tables[deletion.zoneID.zoneName] { + func open(_: T.Type) { + withErrorReporting { + try T.delete().execute(db) + } + } + open(table) + } } } } @@ -150,21 +291,26 @@ extension SyncEngine: CKSyncEngineDelegate { } } -extension Database { - fileprivate func deleteAll(tableName: String) { - withErrorReporting { - try SQLQueryExpression("DELETE FROM \(quote: tableName)").execute(self) - } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine: TestDependencyKey { + public static var testValue: SyncEngine { + SyncEngine(container: .default(), database: try! DatabaseQueue(), tables: []) } } +extension String { + fileprivate static let sharingGRDBCloudKitDatabaseName = "sharing_grdb_icloud" +} + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) extension URL { - fileprivate static func stateSerialization(container: CKContainer) throws -> Self { - try FileManager.default - .createDirectory(at: applicationSupportDirectory, withIntermediateDirectories: true) + fileprivate static func metadatabase(container: CKContainer) throws -> Self { + try FileManager.default.createDirectory( + at: applicationSupportDirectory, + withIntermediateDirectories: true + ) return applicationSupportDirectory.appending( - component: "sharing-grdb-icloud\(container.containerIdentifier.map { ".\($0)" } ?? "").json" + component: "\(container.containerIdentifier.map { "\($0)." } ?? "")sharing-grdb-icloud.sqlite" ) } } From 74fa93d52405665be66a505908a980b2de098e1d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 14 May 2025 17:45:49 -0500 Subject: [PATCH 017/581] wip --- Examples/Reminders/Schema.swift | 4 + Sources/SharingGRDBCore/CloudKit2.swift | 114 ++++++++++++++++++++---- 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 09e0dd46..a6e59b63 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -225,6 +225,10 @@ func appDatabase() throws -> any DatabaseWriter { .execute(db) } + migrator.registerMigration("foo") { db in + try #sql("alter table tags add column hello text").execute(db) + } + #if DEBUG && targetEnvironment(simulator) if context != .test { migrator.registerMigration("Seed sample data") { db in diff --git a/Sources/SharingGRDBCore/CloudKit2.swift b/Sources/SharingGRDBCore/CloudKit2.swift index 1fc11c03..370bf8b0 100644 --- a/Sources/SharingGRDBCore/CloudKit2.swift +++ b/Sources/SharingGRDBCore/CloudKit2.swift @@ -46,8 +46,7 @@ public final actor SyncEngine { ) logger.info( """ - Opened metadatabase - \(metadatabaseURL.absoluteString) + open "\(metadatabaseURL.path(percentEncoded: false))" """ ) var migrator = DatabaseMigrator() @@ -55,6 +54,7 @@ public final actor SyncEngine { migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create Metadata Tables") { db in + // TODO: make proper Record type with @Table macro inlined try SQLQueryExpression( """ CREATE TABLE "records" ( @@ -70,12 +70,13 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TABLE "zones" ( - "zoneName" TEXT PRIMARY KEY NOT NULL - "columnNames" TEXT + "zoneName" TEXT PRIMARY KEY NOT NULL, + "schema" TEXT NOT NULL ) STRICT """ ) .execute(db) + // TODO: make proper StateSerialization type with @Table macro inlined try SQLQueryExpression( """ CREATE TABLE "stateSerialization" ( @@ -100,32 +101,42 @@ public final actor SyncEngine { } ?? nil } - let existingTables = try metadatabase.read { db in + let previousZones = try metadatabase.read { db in + try Zone.all.fetchAll(db) + } + + let currentZones = try database.read { db in try SQLQueryExpression( """ - SELECT "zoneName" FROM "zones" + SELECT "name", "sql" + FROM "sqlite_master" + WHERE "type" = 'table' + AND "name" IN (\(tables.keys.map(\.queryFragment).joined(separator: ","))) """, - as: String.self + as: Zone.self ) .fetchAll(db) } - let newTables = Set(tables.keys).subtracting(existingTables) - if !newTables.isEmpty { + + let zonesToFetch = currentZones.filter { currentZone in + guard let existingZone = previousZones + .first(where: { previousZone in currentZone.zoneName == previousZone.zoneName }) + else { return true } + return existingZone.schema != currentZone.schema + } + + if !zonesToFetch.isEmpty { Task { await withErrorReporting { try await underlyingSyncEngine.fetchChanges( CKSyncEngine.FetchChangesOptions( - scope: .zoneIDs(newTables.map { CKRecordZone(zoneName: $0).zoneID }) + scope: .zoneIDs(zonesToFetch.map { CKRecordZone(zoneName: $0.zoneName).zoneID }) ) ) try await metadatabase.write { db in - try SQLQueryExpression( - """ - INSERT INTO "zones" ("zoneName") - VALUES \(newTables.map { QueryFragment("(\(bind: $0))") }.joined(separator: ", ")) - """ - ) - .execute(db) + for zone in zonesToFetch { + try Zone.upsert(Zone.Draft(zone)).execute(db) + } } } } @@ -177,6 +188,7 @@ public final actor SyncEngine { } } +//select sql from sqlite_master where name = 'reminders'; @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { @@ -317,3 +329,71 @@ extension URL { @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") + +struct Zone { + let zoneName: String + let schema: String +} + +extension Zone: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { + public typealias QueryValue = Zone + public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) + public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) + public var primaryKey: StructuredQueriesCore.TableColumn { + self.zoneName + } + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.zoneName, QueryValue.columns.schema] + } + } + public struct Draft: StructuredQueriesCore.TableDraft { + public typealias PrimaryTable = Zone + let zoneName: String? + let schema: String + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = Zone.Draft + public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) + public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.zoneName, QueryValue.columns.schema] + } + } + public static let columns = TableColumns() + public static let tableName = Zone.tableName + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + self.zoneName = try decoder.decode(String.self) + let schema = try decoder.decode(String.self) + guard let schema else { + throw QueryDecodingError.missingRequiredColumn + } + self.schema = schema + } + public init(_ other: Zone) { + self.zoneName = other.zoneName + self.schema = other.schema + } + public init( + zoneName: String? = nil, + schema: String + ) { + self.zoneName = zoneName + self.schema = schema + } + } + public static let columns = TableColumns() + public static let tableName = "zones" + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let zoneName = try decoder.decode(String.self) + let schema = try decoder.decode(String.self) + guard let zoneName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let schema else { + throw QueryDecodingError.missingRequiredColumn + } + self.zoneName = zoneName + self.schema = schema + } +} + From 582a0ad6ed9c91823b7d289dcdfc1bad12763db0 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 14 May 2025 15:58:27 -0700 Subject: [PATCH 018/581] wip --- .../SyncEngine.swift} | 96 +++---------------- Sources/SharingGRDBCore/CloudKit/Zone.swift | 74 ++++++++++++++ .../{CloudKit.swift => CloudKitOld.swift} | 0 3 files changed, 88 insertions(+), 82 deletions(-) rename Sources/SharingGRDBCore/{CloudKit2.swift => CloudKit/SyncEngine.swift} (74%) create mode 100644 Sources/SharingGRDBCore/CloudKit/Zone.swift rename Sources/SharingGRDBCore/{CloudKit.swift => CloudKitOld.swift} (100%) diff --git a/Sources/SharingGRDBCore/CloudKit2.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift similarity index 74% rename from Sources/SharingGRDBCore/CloudKit2.swift rename to Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 370bf8b0..2b8cb206 100644 --- a/Sources/SharingGRDBCore/CloudKit2.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -81,7 +81,7 @@ public final actor SyncEngine { """ CREATE TABLE "stateSerialization" ( "id" INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK ("id" = 1), - "data" TEXT + "data" TEXT NOT NULL ) STRICT """ ) @@ -89,17 +89,15 @@ public final actor SyncEngine { } try migrator.migrate(metadatabase) withErrorReporting { - stateSerialization = - try metadatabase.read { db in - try SQLQueryExpression( - """ - SELECT "data" FROM "stateSerialization" LIMIT 1 - """, - as: CKSyncEngine.State.Serialization?.JSONRepresentation.self - ) - .fetchOne(db) - } - ?? nil + stateSerialization = try metadatabase.read { db in + try SQLQueryExpression( + """ + SELECT "data" FROM "stateSerialization" LIMIT 1 + """, + as: CKSyncEngine.State.Serialization.JSONRepresentation.self + ) + .fetchOne(db) + } } let previousZones = try metadatabase.read { db in try Zone.all.fetchAll(db) @@ -119,8 +117,10 @@ public final actor SyncEngine { } let zonesToFetch = currentZones.filter { currentZone in - guard let existingZone = previousZones - .first(where: { previousZone in currentZone.zoneName == previousZone.zoneName }) + guard + let existingZone = + previousZones + .first(where: { previousZone in currentZone.zoneName == previousZone.zoneName }) else { return true } return existingZone.schema != currentZone.schema } @@ -329,71 +329,3 @@ extension URL { @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") - -struct Zone { - let zoneName: String - let schema: String -} - -extension Zone: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { - public typealias QueryValue = Zone - public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) - public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) - public var primaryKey: StructuredQueriesCore.TableColumn { - self.zoneName - } - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.zoneName, QueryValue.columns.schema] - } - } - public struct Draft: StructuredQueriesCore.TableDraft { - public typealias PrimaryTable = Zone - let zoneName: String? - let schema: String - public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = Zone.Draft - public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) - public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.zoneName, QueryValue.columns.schema] - } - } - public static let columns = TableColumns() - public static let tableName = Zone.tableName - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - self.zoneName = try decoder.decode(String.self) - let schema = try decoder.decode(String.self) - guard let schema else { - throw QueryDecodingError.missingRequiredColumn - } - self.schema = schema - } - public init(_ other: Zone) { - self.zoneName = other.zoneName - self.schema = other.schema - } - public init( - zoneName: String? = nil, - schema: String - ) { - self.zoneName = zoneName - self.schema = schema - } - } - public static let columns = TableColumns() - public static let tableName = "zones" - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let zoneName = try decoder.decode(String.self) - let schema = try decoder.decode(String.self) - guard let zoneName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let schema else { - throw QueryDecodingError.missingRequiredColumn - } - self.zoneName = zoneName - self.schema = schema - } -} - diff --git a/Sources/SharingGRDBCore/CloudKit/Zone.swift b/Sources/SharingGRDBCore/CloudKit/Zone.swift new file mode 100644 index 00000000..7481f6b5 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Zone.swift @@ -0,0 +1,74 @@ +// @Table +struct Zone { + // @Column(primaryKey: true) + let zoneName: String + let schema: String +} + +extension Zone: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore + .PrimaryKeyedTableDefinition + { + public typealias QueryValue = Zone + public let zoneName = StructuredQueriesCore.TableColumn( + "zoneName", keyPath: \QueryValue.zoneName) + public let schema = StructuredQueriesCore.TableColumn( + "schema", keyPath: \QueryValue.schema) + public var primaryKey: StructuredQueriesCore.TableColumn { + self.zoneName + } + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.zoneName, QueryValue.columns.schema] + } + } + public struct Draft: StructuredQueriesCore.TableDraft { + public typealias PrimaryTable = Zone + let zoneName: String? + let schema: String + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = Zone.Draft + public let zoneName = StructuredQueriesCore.TableColumn( + "zoneName", keyPath: \QueryValue.zoneName) + public let schema = StructuredQueriesCore.TableColumn( + "schema", keyPath: \QueryValue.schema) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.zoneName, QueryValue.columns.schema] + } + } + public static let columns = TableColumns() + public static let tableName = Zone.tableName + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + self.zoneName = try decoder.decode(String.self) + let schema = try decoder.decode(String.self) + guard let schema else { + throw QueryDecodingError.missingRequiredColumn + } + self.schema = schema + } + public init(_ other: Zone) { + self.zoneName = other.zoneName + self.schema = other.schema + } + public init( + zoneName: String? = nil, + schema: String + ) { + self.zoneName = zoneName + self.schema = schema + } + } + public static let columns = TableColumns() + public static let tableName = "zones" + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let zoneName = try decoder.decode(String.self) + let schema = try decoder.decode(String.self) + guard let zoneName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let schema else { + throw QueryDecodingError.missingRequiredColumn + } + self.zoneName = zoneName + self.schema = schema + } +} diff --git a/Sources/SharingGRDBCore/CloudKit.swift b/Sources/SharingGRDBCore/CloudKitOld.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit.swift rename to Sources/SharingGRDBCore/CloudKitOld.swift From 71270cca9af0c83df9d384164bac4adc3e1feb28 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 14 May 2025 17:33:51 -0700 Subject: [PATCH 019/581] wip --- Examples/Reminders/Schema.swift | 8 +- Examples/SyncUps/Schema.swift | 8 +- Sources/SharingGRDBCore/CloudKit/Record.swift | 54 ++++++++++++ .../CloudKit/StateSerialization.swift | 84 +++++++++++++++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 33 ++------ Sources/SharingGRDBCore/CloudKit/Zone.swift | 1 + 6 files changed, 159 insertions(+), 29 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/Record.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/StateSerialization.swift diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index a6e59b63..91073375 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -103,8 +103,12 @@ func appDatabase() throws -> any DatabaseWriter { #endif } if context == .live { - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() - logger.info("open \(path)") + let path = URL.documentsDirectory.appending(component: "db.sqlite").path(percentEncoded: false) + logger.info( + """ + open "\(path)" + """ + ) database = try DatabasePool(path: path, configuration: configuration) } else { database = try DatabaseQueue(configuration: configuration) diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index b1932a5f..25e1ab9e 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -89,8 +89,12 @@ func appDatabase() throws -> any DatabaseWriter { } @Dependency(\.context) var context if context == .live { - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() - logger.info("open \(path)") + let path = URL.documentsDirectory.appending(component: "db.sqlite").path(percentEncoded: false) + logger.info( + """ + open "\(path)" + """ + ) database = try DatabasePool(path: path, configuration: configuration) } else { database = try DatabaseQueue(configuration: configuration) diff --git a/Sources/SharingGRDBCore/CloudKit/Record.swift b/Sources/SharingGRDBCore/CloudKit/Record.swift new file mode 100644 index 00000000..0d672d6e --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Record.swift @@ -0,0 +1,54 @@ +import Foundation + +// @Table +struct Record { + var zoneName: String + var recordName: String + var recordData: Data? + var userModificationDate: Date? +} + +// NB: This is generated by inlining the above macro applications. +extension Record: StructuredQueriesCore.Table { + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = Record + public let zoneName = StructuredQueriesCore.TableColumn( + "zoneName", + keyPath: \QueryValue.zoneName + ) + public let recordName = StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + public let recordData = StructuredQueriesCore.TableColumn( + "recordData", + keyPath: \QueryValue.recordData + ) + public let userModificationDate = StructuredQueriesCore.TableColumn( + "userModificationDate", + keyPath: \QueryValue.userModificationDate + ) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [ + QueryValue.columns.zoneName, QueryValue.columns.recordName, QueryValue.columns.recordData, + QueryValue.columns.userModificationDate, + ] + } + } + public static let columns = TableColumns() + public static let tableName = "records" + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let zoneName = try decoder.decode(String.self) + let recordName = try decoder.decode(String.self) + self.recordData = try decoder.decode(Data.self) + self.userModificationDate = try decoder.decode(Date.self) + guard let zoneName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let recordName else { + throw QueryDecodingError.missingRequiredColumn + } + self.zoneName = zoneName + self.recordName = recordName + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift new file mode 100644 index 00000000..798a72e1 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift @@ -0,0 +1,84 @@ +import CloudKit + +// @Table("stateSerialization") +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +struct StateSerialization { + let id: Int + var data: CKSyncEngine.State.Serialization +} + +// NB: This is generated by inlining the above macro applications. +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore + .PrimaryKeyedTableDefinition + { + public typealias QueryValue = StateSerialization + public let id = StructuredQueriesCore.TableColumn( + "id", + keyPath: \QueryValue.id + ) + public let data = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation + >("data", keyPath: \QueryValue.data) + public var primaryKey: StructuredQueriesCore.TableColumn { + self.id + } + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.id, QueryValue.columns.data] + } + } + public struct Draft: StructuredQueriesCore.TableDraft { + public typealias PrimaryTable = StateSerialization + let id: Int? + var data: CKSyncEngine.State.Serialization + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = StateSerialization.Draft + public let id = StructuredQueriesCore.TableColumn( + "id", + keyPath: \QueryValue.id + ) + public let data = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation + >("data", keyPath: \QueryValue.data) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.id, QueryValue.columns.data] + } + } + public static let columns = TableColumns() + public static let tableName = StateSerialization.tableName + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + self.id = try decoder.decode(Int.self) + let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) + guard let data else { + throw QueryDecodingError.missingRequiredColumn + } + self.data = data + } + public init(_ other: StateSerialization) { + self.id = other.id + self.data = other.data + } + public init( + id: Int? = nil, + data: CKSyncEngine.State.Serialization + ) { + self.id = id + self.data = data + } + } + public static let columns = TableColumns() + public static let tableName = "stateSerialization" + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let id = try decoder.decode(Int.self) + let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) + guard let id else { + throw QueryDecodingError.missingRequiredColumn + } + guard let data else { + throw QueryDecodingError.missingRequiredColumn + } + self.id = id + self.data = data + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 2b8cb206..69acf6c6 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -41,7 +41,7 @@ public final actor SyncEngine { } } let metadatabase = try DatabaseQueue( - path: metadatabaseURL.absoluteString, + path: metadatabaseURL.path(percentEncoded: false), configuration: configuration ) logger.info( @@ -54,7 +54,6 @@ public final actor SyncEngine { migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create Metadata Tables") { db in - // TODO: make proper Record type with @Table macro inlined try SQLQueryExpression( """ CREATE TABLE "records" ( @@ -76,7 +75,6 @@ public final actor SyncEngine { """ ) .execute(db) - // TODO: make proper StateSerialization type with @Table macro inlined try SQLQueryExpression( """ CREATE TABLE "stateSerialization" ( @@ -90,13 +88,7 @@ public final actor SyncEngine { try migrator.migrate(metadatabase) withErrorReporting { stateSerialization = try metadatabase.read { db in - try SQLQueryExpression( - """ - SELECT "data" FROM "stateSerialization" LIMIT 1 - """, - as: CKSyncEngine.State.Serialization.JSONRepresentation.self - ) - .fetchOne(db) + try StateSerialization.all.fetchOne(db)?.data } } let previousZones = try metadatabase.read { db in @@ -109,7 +101,7 @@ public final actor SyncEngine { SELECT "name", "sql" FROM "sqlite_master" WHERE "type" = 'table' - AND "name" IN (\(tables.keys.map(\.queryFragment).joined(separator: ","))) + AND "name" IN (\(tables.keys.map(\.queryFragment).joined(separator: ", "))) """, as: Zone.self ) @@ -119,8 +111,9 @@ public final actor SyncEngine { let zonesToFetch = currentZones.filter { currentZone in guard let existingZone = - previousZones - .first(where: { previousZone in currentZone.zoneName == previousZone.zoneName }) + previousZones.first(where: { previousZone in + currentZone.zoneName == previousZone.zoneName + }) else { return true } return existingZone.schema != currentZone.schema } @@ -188,7 +181,6 @@ public final actor SyncEngine { } } -//select sql from sqlite_master where name = 'reminders'; @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { @@ -199,17 +191,8 @@ extension SyncEngine: CKSyncEngineDelegate { stateSerialization = event.stateSerialization withErrorReporting { try database.write { db in - let data = BindQueryExpression( - event.stateSerialization, - as: CKSyncEngine.State.Serialization.JSONRepresentation.self - ) - try SQLQueryExpression( - """ - INSERT INTO \(quote: .sharingGRDBCloudKitDatabaseName)."stateSerialization" - ("id", "data") - VALUES - (1, \(data)) - """ + try StateSerialization.insert( + StateSerialization.Draft(id: 1, data: event.stateSerialization) ) .execute(db) } diff --git a/Sources/SharingGRDBCore/CloudKit/Zone.swift b/Sources/SharingGRDBCore/CloudKit/Zone.swift index 7481f6b5..14931be2 100644 --- a/Sources/SharingGRDBCore/CloudKit/Zone.swift +++ b/Sources/SharingGRDBCore/CloudKit/Zone.swift @@ -5,6 +5,7 @@ struct Zone { let schema: String } +// NB: This is generated by inlining the above macro applications. extension Zone: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore .PrimaryKeyedTableDefinition From 8a262250277e42cda9d07169f2650a6e570c4805 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 14 May 2025 17:42:17 -0700 Subject: [PATCH 020/581] wip --- .../CloudKit/StateSerialization.swift | 62 +++---------------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 8 +-- 2 files changed, 11 insertions(+), 59 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift index 798a72e1..2c87dde8 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift @@ -3,82 +3,36 @@ import CloudKit // @Table("stateSerialization") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) struct StateSerialization { - let id: Int + var id = 1 + // @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) var data: CKSyncEngine.State.Serialization } // NB: This is generated by inlining the above macro applications. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore - .PrimaryKeyedTableDefinition - { +extension StateSerialization: StructuredQueriesCore.Table { + public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = StateSerialization - public let id = StructuredQueriesCore.TableColumn( + public let id = StructuredQueriesCore.TableColumn( "id", - keyPath: \QueryValue.id + keyPath: \QueryValue.id, + default: 1 ) public let data = StructuredQueriesCore.TableColumn< QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation >("data", keyPath: \QueryValue.data) - public var primaryKey: StructuredQueriesCore.TableColumn { - self.id - } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.id, QueryValue.columns.data] } } - public struct Draft: StructuredQueriesCore.TableDraft { - public typealias PrimaryTable = StateSerialization - let id: Int? - var data: CKSyncEngine.State.Serialization - public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = StateSerialization.Draft - public let id = StructuredQueriesCore.TableColumn( - "id", - keyPath: \QueryValue.id - ) - public let data = StructuredQueriesCore.TableColumn< - QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation - >("data", keyPath: \QueryValue.data) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.id, QueryValue.columns.data] - } - } - public static let columns = TableColumns() - public static let tableName = StateSerialization.tableName - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - self.id = try decoder.decode(Int.self) - let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) - guard let data else { - throw QueryDecodingError.missingRequiredColumn - } - self.data = data - } - public init(_ other: StateSerialization) { - self.id = other.id - self.data = other.data - } - public init( - id: Int? = nil, - data: CKSyncEngine.State.Serialization - ) { - self.id = id - self.data = data - } - } public static let columns = TableColumns() public static let tableName = "stateSerialization" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let id = try decoder.decode(Int.self) + self.id = try decoder.decode(Swift.Int.self) ?? 1 let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) - guard let id else { - throw QueryDecodingError.missingRequiredColumn - } guard let data else { throw QueryDecodingError.missingRequiredColumn } - self.id = id self.data = data } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 69acf6c6..43d717f3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -86,10 +86,8 @@ public final actor SyncEngine { .execute(db) } try migrator.migrate(metadatabase) - withErrorReporting { - stateSerialization = try metadatabase.read { db in - try StateSerialization.all.fetchOne(db)?.data - } + stateSerialization = try metadatabase.read { db in + try StateSerialization.all.fetchOne(db)?.data } let previousZones = try metadatabase.read { db in try Zone.all.fetchAll(db) @@ -192,7 +190,7 @@ extension SyncEngine: CKSyncEngineDelegate { withErrorReporting { try database.write { db in try StateSerialization.insert( - StateSerialization.Draft(id: 1, data: event.stateSerialization) + StateSerialization(id: 1, data: event.stateSerialization) ) .execute(db) } From 015217303c8c8255dc4de5e4e80e0717f5958ede Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 14 May 2025 21:55:56 -0700 Subject: [PATCH 021/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 43d717f3..f591e6dc 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -26,7 +26,7 @@ public final actor SyncEngine { self.database = database self.tables = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) Task { - await withErrorReporting("SharingGRDB CloudKit Failure") { + await withErrorReporting(.sharingGRDBCloudKitFailure) { try await setUpSyncEngine() } } @@ -118,7 +118,7 @@ public final actor SyncEngine { if !zonesToFetch.isEmpty { Task { - await withErrorReporting { + await withErrorReporting(.sharingGRDBCloudKitFailure) { try await underlyingSyncEngine.fetchChanges( CKSyncEngine.FetchChangesOptions( scope: .zoneIDs(zonesToFetch.map { CKRecordZone(zoneName: $0.zoneName).zoneID }) @@ -152,11 +152,11 @@ public final actor SyncEngine { } func deleteLocalData() throws { - withErrorReporting { + withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in for table in tables.values { func open(_: T.Type) { - withErrorReporting { + withErrorReporting(.sharingGRDBCloudKitFailure) { try T.delete().execute(db) } } @@ -187,7 +187,7 @@ extension SyncEngine: CKSyncEngineDelegate { handleAccountChange(event) case .stateUpdate(let event): stateSerialization = event.stateSerialization - withErrorReporting { + withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in try StateSerialization.insert( StateSerialization(id: 1, data: event.stateSerialization) @@ -226,7 +226,7 @@ extension SyncEngine: CKSyncEngineDelegate { underlyingSyncEngine.state.add( pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneName: table.tableName))] ) - withErrorReporting { + withErrorReporting(.sharingGRDBCloudKitFailure) { let names: [String] = try database.read { db in func open(_ table: T.Type) throws -> [String] { try SQLQueryExpression( @@ -250,7 +250,7 @@ extension SyncEngine: CKSyncEngineDelegate { } } case .signOut, .switchAccounts: - withErrorReporting { + withErrorReporting(.sharingGRDBCloudKitFailure) { try deleteLocalData() } @unknown default: @@ -259,12 +259,12 @@ extension SyncEngine: CKSyncEngineDelegate { } private func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { - withErrorReporting { + withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in for deletion in event.deletions { if let table = tables[deletion.zoneID.zoneName] { func open(_: T.Type) { - withErrorReporting { + withErrorReporting(.sharingGRDBCloudKitFailure) { try T.delete().execute(db) } } @@ -293,6 +293,7 @@ extension SyncEngine: TestDependencyKey { extension String { fileprivate static let sharingGRDBCloudKitDatabaseName = "sharing_grdb_icloud" + fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" } @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) From cc3a4a1e3972f62776b86dca6ba840ee45142dc3 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 14 May 2025 23:39:38 -0700 Subject: [PATCH 022/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 88 +++++++++++++++++++ Sources/SharingGRDBCore/CloudKitOld.swift | 11 ++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index f591e6dc..dedd23ed 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -137,10 +137,18 @@ public final actor SyncEngine { "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitDatabaseName)" ) .execute(db) + db.add(function: .didInsert) + db.add(function: .didUpdate) + db.add(function: .willDelete) } } func tearDownSyncEngine() throws { + try database.write { db in + db.remove(function: .willDelete) + db.remove(function: .didUpdate) + db.remove(function: .didInsert) + } let metadatabaseURL = try URL.metadatabase(container: container) try database.write { db in try SQLQueryExpression( @@ -168,6 +176,46 @@ public final actor SyncEngine { try setUpSyncEngine() } + func didInsert(recordName: String, zoneName: String) { + underlyingSyncEngine.state.add( + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: recordName, + zoneID: CKRecordZone(zoneName: zoneName).zoneID + ) + ) + ] + ) + } + + func didUpdate(recordName: String, zoneName: String) { + // TODO: Check user modification dates + underlyingSyncEngine.state.add( + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: recordName, + zoneID: CKRecordZone(zoneName: zoneName).zoneID + ) + ) + ] + ) + } + + func willDelete(recordName: String, zoneName: String) { + underlyingSyncEngine.state.add( + pendingRecordZoneChanges: [ + .deleteRecord( + CKRecord.ID( + recordName: recordName, + zoneID: CKRecordZone(zoneName: zoneName).zoneID + ) + ) + ] + ) + } + private var defaultSyncEngine: CKSyncEngine { CKSyncEngine( CKSyncEngine.Configuration( @@ -291,6 +339,46 @@ extension SyncEngine: TestDependencyKey { } } +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +extension DatabaseFunction { + fileprivate static var didInsert: Self { + Self("didInsert") { + @Dependency(\.defaultSyncEngine) var defaultSyncEngine + await defaultSyncEngine.didInsert(recordName: $0, zoneName: $1) + } + } + + fileprivate static var didUpdate: Self { + Self("didUpdate") { + @Dependency(\.defaultSyncEngine) var defaultSyncEngine + await defaultSyncEngine.didUpdate(recordName: $0, zoneName: $1) + } + } + + fileprivate static var willDelete: Self { + Self("willDelete") { + @Dependency(\.defaultSyncEngine) var defaultSyncEngine + await defaultSyncEngine.willDelete(recordName: $0, zoneName: $1) + } + } + + fileprivate convenience init( + _ name: String, + function: @escaping @Sendable (String, String) async -> Void + ) { + self.init(name, argumentCount: 2) { arguments in + guard + let tableName = String.fromDatabaseValue(arguments[0]), + let id = String.fromDatabaseValue(arguments[1]) + else { + return nil + } + Task { await function(tableName, id) } + return nil + } + } +} + extension String { fileprivate static let sharingGRDBCloudKitDatabaseName = "sharing_grdb_icloud" fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" diff --git a/Sources/SharingGRDBCore/CloudKitOld.swift b/Sources/SharingGRDBCore/CloudKitOld.swift index 0aab9c8e..2d1763b2 100644 --- a/Sources/SharingGRDBCore/CloudKitOld.swift +++ b/Sources/SharingGRDBCore/CloudKitOld.swift @@ -667,7 +667,10 @@ } extension DatabaseFunction { - convenience init(name: String, function: @escaping @Sendable (String, String) async -> Void) { + fileprivate convenience init( + name: String, + function: @escaping @Sendable (String, String) async -> Void + ) { self.init(name, argumentCount: 2) { arguments in guard let tableName = String.fromDatabaseValue(arguments[0]), @@ -926,21 +929,21 @@ @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { - static var didInsert: Self { + fileprivate static var didInsert: Self { @Dependency(\.cloudKitDatabase) var cloudKitDatabase return Self( name: "didInsert", function: { await cloudKitDatabase.didInsert(tableName: $0, id: $1) } ) } - static var didUpdate: Self { + fileprivate static var didUpdate: Self { @Dependency(\.cloudKitDatabase) var cloudKitDatabase return Self( name: "didUpdate", function: { await cloudKitDatabase.didUpdate(tableName: $0, id: $1) } ) } - static var willDelete: Self { + fileprivate static var willDelete: Self { @Dependency(\.cloudKitDatabase) var cloudKitDatabase return Self( name: "willDelete", From a171c7995b2a1888ce10678bdb5b4ae16567a185 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 15 May 2025 16:33:40 -0700 Subject: [PATCH 023/581] wip --- .../xcshareddata/swiftpm/Package.resolved | 20 +- Sources/SharingGRDBCore/CloudKit/Record.swift | 73 ++++++-- .../CloudKit/StateSerialization.swift | 4 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 174 ++++++++++++++++-- Sources/SharingGRDBCore/CloudKit/Zone.swift | 4 +- Sources/SharingGRDBCore/CloudKitOld.swift | 2 +- 6 files changed, 231 insertions(+), 46 deletions(-) diff --git a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9921ede0..94b17ac1 100644 --- a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9c071e96c29584bec134f4f8a5f344125ef801fc238a2c7f9a67c8eb135180d9", + "originHash" : "33215ac6966f7a6274a68634cf84cde00e0f1f2e3dcef3f092d9e446837c79b4", "pins" : [ { "identity" : "combine-schedulers", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "04e73c26c4ce8218ab85aaf791942bb0b204f330", - "version" : "7.4.1" + "revision" : "a5a1be26b4513dc7ec360eb56bc08a345bac6649", + "version" : "7.5.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "fee6aa29908a75437506ddcbe7434c460605b7e6", - "version" : "1.9.1" + "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", + "version" : "1.9.2" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "732871fabfc6b38fcdff5ad2f7336327dbf78e81", - "version" : "2.4.0" + "revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9", + "version" : "2.5.2" } }, { @@ -142,7 +142,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "default-date-uuid-representations", - "revision" : "97adefc550df097caff1b5de30bd6add6b9fba69" + "revision" : "e56c2aecbf1da086ddc076c9d5a916cb4614815f" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "0687f71944021d616d34d922343dcef086855920", - "version" : "600.0.1" + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/Record.swift b/Sources/SharingGRDBCore/CloudKit/Record.swift index 0d672d6e..c6b7b8de 100644 --- a/Sources/SharingGRDBCore/CloudKit/Record.swift +++ b/Sources/SharingGRDBCore/CloudKit/Record.swift @@ -1,14 +1,52 @@ +import CloudKit import Foundation -// @Table +extension CKRecord { + struct DataRepresentation: QueryBindable, QueryRepresentable { + let queryOutput: CKRecord + + var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encodeSystemFields(with: archiver) + return archiver.encodedData.queryBinding + } + + init(queryOutput: CKRecord) { + self.queryOutput = queryOutput + } + + init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + guard let data = try Data?(decoder: &decoder) else { + throw QueryDecodingError.missingRequiredColumn + } + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = CKRecord(coder: coder) else { + throw DecodingError() + } + self.init(queryOutput: queryOutput) + } + + private struct DecodingError: Error {} + } +} + +extension CKRecord? { + typealias DataRepresentation = CKRecord.DataRepresentation? +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Table("sharing_grdb_cloudkit_records") struct Record { var zoneName: String var recordName: String - var recordData: Data? - var userModificationDate: Date? + // @Column(as: CKRecord.DataRepresentation.self) + var recordData: CKRecord + var localModificationDate: Date } // NB: This is generated by inlining the above macro applications. +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Record: StructuredQueriesCore.Table { public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Record @@ -20,35 +58,42 @@ extension Record: StructuredQueriesCore.Table { "recordName", keyPath: \QueryValue.recordName ) - public let recordData = StructuredQueriesCore.TableColumn( - "recordData", - keyPath: \QueryValue.recordData - ) - public let userModificationDate = StructuredQueriesCore.TableColumn( - "userModificationDate", - keyPath: \QueryValue.userModificationDate + public let recordData = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord.DataRepresentation + >("recordData", keyPath: \QueryValue.recordData) + public let localModificationDate = StructuredQueriesCore.TableColumn( + "localModificationDate", + keyPath: \QueryValue.localModificationDate ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [ QueryValue.columns.zoneName, QueryValue.columns.recordName, QueryValue.columns.recordData, - QueryValue.columns.userModificationDate, + QueryValue.columns.localModificationDate, ] } } public static let columns = TableColumns() - public static let tableName = "records" + public static let tableName = "sharing_grdb_cloudkit_records" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let zoneName = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) - self.recordData = try decoder.decode(Data.self) - self.userModificationDate = try decoder.decode(Date.self) + let recordData = try decoder.decode(CKRecord.DataRepresentation.self) + let localModificationDate = try decoder.decode(Date.self) guard let zoneName else { throw QueryDecodingError.missingRequiredColumn } guard let recordName else { throw QueryDecodingError.missingRequiredColumn } + guard let recordData else { + throw QueryDecodingError.missingRequiredColumn + } + guard let localModificationDate else { + throw QueryDecodingError.missingRequiredColumn + } self.zoneName = zoneName self.recordName = recordName + self.recordData = recordData + self.localModificationDate = localModificationDate } } diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift index 2c87dde8..6d788f5b 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift @@ -1,6 +1,6 @@ import CloudKit -// @Table("stateSerialization") +// @Table("sharing_grdb_cloudkit_stateSerialization") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) struct StateSerialization { var id = 1 @@ -26,7 +26,7 @@ extension StateSerialization: StructuredQueriesCore.Table { } } public static let columns = TableColumns() - public static let tableName = "stateSerialization" + public static let tableName = "sharing_grdb_cloudkit_stateSerialization" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { self.id = try decoder.decode(Swift.Int.self) ?? 1 let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index dedd23ed..f5c1868a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -56,11 +56,11 @@ public final actor SyncEngine { migrator.registerMigration("Create Metadata Tables") { db in try SQLQueryExpression( """ - CREATE TABLE "records" ( + CREATE TABLE "sharing_grdb_cloudkit_records" ( "zoneName" TEXT NOT NULL, "recordName" TEXT NOT NULL, "recordData" BLOB, - "userModificationDate" TEXT, + "localModificationDate" TEXT, PRIMARY KEY("zoneName", "recordName") ) STRICT """ @@ -68,7 +68,7 @@ public final actor SyncEngine { .execute(db) try SQLQueryExpression( """ - CREATE TABLE "zones" ( + CREATE TABLE "sharing_grdb_cloudkit_zones" ( "zoneName" TEXT PRIMARY KEY NOT NULL, "schema" TEXT NOT NULL ) STRICT @@ -77,7 +77,7 @@ public final actor SyncEngine { .execute(db) try SQLQueryExpression( """ - CREATE TABLE "stateSerialization" ( + CREATE TABLE "sharing_grdb_cloudkit_stateSerialization" ( "id" INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK ("id" = 1), "data" TEXT NOT NULL ) STRICT @@ -92,7 +92,6 @@ public final actor SyncEngine { let previousZones = try metadatabase.read { db in try Zone.all.fetchAll(db) } - let currentZones = try database.read { db in try SQLQueryExpression( """ @@ -105,17 +104,14 @@ public final actor SyncEngine { ) .fetchAll(db) } - let zonesToFetch = currentZones.filter { currentZone in guard - let existingZone = - previousZones.first(where: { previousZone in - currentZone.zoneName == previousZone.zoneName - }) + let existingZone = previousZones.first(where: { previousZone in + currentZone.zoneName == previousZone.zoneName + }) else { return true } return existingZone.schema != currentZone.schema } - if !zonesToFetch.isEmpty { Task { await withErrorReporting(.sharingGRDBCloudKitFailure) { @@ -134,25 +130,87 @@ public final actor SyncEngine { } try database.write { db in try SQLQueryExpression( - "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitDatabaseName)" + "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" ) .execute(db) + db.add(function: .ckRecord) db.add(function: .didInsert) db.add(function: .didUpdate) db.add(function: .willDelete) + for table in tables.values { + func open(_: T.Type) throws { + try SQLQueryExpression( + Trigger(on: T.self, .after, .insert, select: .didInsert).create + ) + .execute(db) + try SQLQueryExpression( + Trigger(on: T.self, .after, .update, select: .didUpdate).create + ) + .execute(db) + try SQLQueryExpression( + Trigger(on: T.self, .before, .delete, select: .willDelete).create + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_localModifications" + AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN + INSERT INTO \(Record.self) + ("zoneName", "recordName", "recordData", "localModificationDate") + VALUES + ( + '\(raw: table.tableName)', + "new".\(quote: T.columns.primaryKey.name), + CKRecord('\(raw: T.tableName)', "new".\(quote: T.columns.primaryKey.name)), + datetime('subsec') + ) + ON CONFLICT("zoneName", "recordName") DO UPDATE SET + "localModificationDate" = "excluded"."localModificationDate"; + END + """ + ) + .execute(db) + } + try open(table) + } } } func tearDownSyncEngine() throws { try database.write { db in + for table in tables.values { + try SQLQueryExpression( + """ + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_localModifications" + """ + ) + .execute(db) + func open(_: T.Type) throws { + try SQLQueryExpression( + Trigger(on: T.self, .before, .delete, select: .willDelete).drop + ) + .execute(db) + try SQLQueryExpression( + Trigger(on: T.self, .after, .update, select: .didUpdate).drop + ) + .execute(db) + try SQLQueryExpression( + Trigger(on: T.self, .after, .insert, select: .didInsert).drop + ) + .execute(db) + } + try open(table) + } db.remove(function: .willDelete) db.remove(function: .didUpdate) db.remove(function: .didInsert) + db.remove(function: .ckRecord) } let metadatabaseURL = try URL.metadatabase(container: container) try database.write { db in try SQLQueryExpression( - "DETACH DATABASE \(quote: .sharingGRDBCloudKitDatabaseName)" + "DETACH DATABASE \(quote: .sharingGRDBCloudKitSchemaName)" ) .execute(db) } @@ -238,12 +296,11 @@ extension SyncEngine: CKSyncEngineDelegate { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in try StateSerialization.insert( - StateSerialization(id: 1, data: event.stateSerialization) + StateSerialization(data: event.stateSerialization) ) .execute(db) } } - case .fetchedDatabaseChanges(let event): handleFetchedDatabaseChanges(event) case .sentDatabaseChanges: @@ -264,10 +321,12 @@ extension SyncEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - nil + logger.debug("nextRecordZoneChangeBatch: \(context)") + return nil } private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { + logger.debug("handleAccountChange: \(event)") switch event.changeType { case .signIn: for table in tables.values { @@ -329,6 +388,27 @@ extension SyncEngine: CKSyncEngineDelegate { } private func handleSentRecordZoneChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) { + var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] + var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] + defer { + underlyingSyncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + underlyingSyncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + } + + for savedRecord in event.savedRecords { + let existingRecord = withErrorReporting { + try database.read { db in + try Record.where { + $0.zoneName.eq(savedRecord.recordID.zoneID.zoneName) + && $0.recordName.eq(savedRecord.recordID.recordName) + } + .fetchOne(db) + } + } + } + + for failedRecordSave in event.failedRecordSaves { + } } } @@ -341,6 +421,21 @@ extension SyncEngine: TestDependencyKey { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { + fileprivate static var ckRecord: Self { + Self("CKRecord", argumentCount: 2) { arguments in + guard + let recordType = String.fromDatabaseValue(arguments[0]), + let recordName = String.fromDatabaseValue(arguments[1]) + else { + return nil + } + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + CKRecord(recordType: recordType, recordID: CKRecord.ID(recordName: recordName)) + .encodeSystemFields(with: archiver) + return archiver.encodedData + } + } + fileprivate static var didInsert: Self { Self("didInsert") { @Dependency(\.defaultSyncEngine) var defaultSyncEngine @@ -379,8 +474,53 @@ extension DatabaseFunction { } } +fileprivate struct Trigger { + typealias QueryValue = Void + + let function: DatabaseFunction + let operation: Operation + let when: When + + init(on _: Base.Type, _ when: When, _ operation: Operation, select function: DatabaseFunction) { + self.function = function + self.operation = operation + self.when = when + } + + var name: QueryFragment { + "\(quote: "sharing_grdb_cloudkit_\(operation.rawValue.string.lowercased())_\(Base.tableName)")" + } + + var create: QueryFragment { + """ + CREATE TEMPORARY TRIGGER \(name) + \(when.rawValue) \(operation.rawValue) ON \(quote: Base.tableName) FOR EACH ROW BEGIN + SELECT \(raw: function.name)( + \(quote: Base.tableName, delimiter: .text), + \(quote: operation == .delete ? "old" : "new").\(quote: Base.columns.primaryKey.name) + ); + END + """ + } + + var drop: QueryFragment { + "DROP TRIGGER \(name)" + } + + enum Operation: QueryFragment { + case insert = "INSERT" + case update = "UPDATE" + case delete = "DELETE" + } + + enum When: QueryFragment { + case before = "BEFORE" + case after = "AFTER" + } +} + extension String { - fileprivate static let sharingGRDBCloudKitDatabaseName = "sharing_grdb_icloud" + fileprivate static let sharingGRDBCloudKitSchemaName = "sharing_grdb_icloud" fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" } diff --git a/Sources/SharingGRDBCore/CloudKit/Zone.swift b/Sources/SharingGRDBCore/CloudKit/Zone.swift index 14931be2..444995ab 100644 --- a/Sources/SharingGRDBCore/CloudKit/Zone.swift +++ b/Sources/SharingGRDBCore/CloudKit/Zone.swift @@ -1,4 +1,4 @@ -// @Table +// @Table("sharing_grdb_cloudkit_zones") struct Zone { // @Column(primaryKey: true) let zoneName: String @@ -59,7 +59,7 @@ extension Zone: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedT } } public static let columns = TableColumns() - public static let tableName = "zones" + public static let tableName = "sharing_grdb_cloudkit_zones" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let zoneName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) diff --git a/Sources/SharingGRDBCore/CloudKitOld.swift b/Sources/SharingGRDBCore/CloudKitOld.swift index 2d1763b2..53d1ed42 100644 --- a/Sources/SharingGRDBCore/CloudKitOld.swift +++ b/Sources/SharingGRDBCore/CloudKitOld.swift @@ -878,7 +878,7 @@ } } - struct Trigger { + fileprivate struct Trigger { let idColumn: String let function: String let tableName: String From e48077568643eea8dffccc9cd152c65720a00f16 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 15 May 2025 16:39:43 -0700 Subject: [PATCH 024/581] wip --- Sources/SharingGRDBCore/CloudKit/Record.swift | 20 ++++++++-------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 24 +++---------------- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Record.swift b/Sources/SharingGRDBCore/CloudKit/Record.swift index c6b7b8de..87e89ce6 100644 --- a/Sources/SharingGRDBCore/CloudKit/Record.swift +++ b/Sources/SharingGRDBCore/CloudKit/Record.swift @@ -40,8 +40,8 @@ extension CKRecord? { struct Record { var zoneName: String var recordName: String - // @Column(as: CKRecord.DataRepresentation.self) - var recordData: CKRecord + // @Column(as: CKRecord?.DataRepresentation.self) + var lastKnownServerRecord: CKRecord? var localModificationDate: Date } @@ -58,17 +58,17 @@ extension Record: StructuredQueriesCore.Table { "recordName", keyPath: \QueryValue.recordName ) - public let recordData = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord.DataRepresentation - >("recordData", keyPath: \QueryValue.recordData) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.DataRepresentation + >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) public let localModificationDate = StructuredQueriesCore.TableColumn( "localModificationDate", keyPath: \QueryValue.localModificationDate ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [ - QueryValue.columns.zoneName, QueryValue.columns.recordName, QueryValue.columns.recordData, - QueryValue.columns.localModificationDate, + QueryValue.columns.zoneName, QueryValue.columns.recordName, + QueryValue.columns.lastKnownServerRecord, QueryValue.columns.localModificationDate, ] } } @@ -77,7 +77,7 @@ extension Record: StructuredQueriesCore.Table { public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let zoneName = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) - let recordData = try decoder.decode(CKRecord.DataRepresentation.self) + let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) let localModificationDate = try decoder.decode(Date.self) guard let zoneName else { throw QueryDecodingError.missingRequiredColumn @@ -85,7 +85,7 @@ extension Record: StructuredQueriesCore.Table { guard let recordName else { throw QueryDecodingError.missingRequiredColumn } - guard let recordData else { + guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } guard let localModificationDate else { @@ -93,7 +93,7 @@ extension Record: StructuredQueriesCore.Table { } self.zoneName = zoneName self.recordName = recordName - self.recordData = recordData + self.lastKnownServerRecord = lastKnownServerRecord self.localModificationDate = localModificationDate } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index f5c1868a..bb42aeb8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -59,8 +59,8 @@ public final actor SyncEngine { CREATE TABLE "sharing_grdb_cloudkit_records" ( "zoneName" TEXT NOT NULL, "recordName" TEXT NOT NULL, - "recordData" BLOB, - "localModificationDate" TEXT, + "lastKnownServerRecord" BLOB, + "localModificationDate" TEXT NOT NULL, PRIMARY KEY("zoneName", "recordName") ) STRICT """ @@ -133,7 +133,6 @@ public final actor SyncEngine { "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" ) .execute(db) - db.add(function: .ckRecord) db.add(function: .didInsert) db.add(function: .didUpdate) db.add(function: .willDelete) @@ -157,12 +156,11 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_localModifications" AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Record.self) - ("zoneName", "recordName", "recordData", "localModificationDate") + ("zoneName", "recordName", "localModificationDate") VALUES ( '\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name), - CKRecord('\(raw: T.tableName)', "new".\(quote: T.columns.primaryKey.name)), datetime('subsec') ) ON CONFLICT("zoneName", "recordName") DO UPDATE SET @@ -205,7 +203,6 @@ public final actor SyncEngine { db.remove(function: .willDelete) db.remove(function: .didUpdate) db.remove(function: .didInsert) - db.remove(function: .ckRecord) } let metadatabaseURL = try URL.metadatabase(container: container) try database.write { db in @@ -421,21 +418,6 @@ extension SyncEngine: TestDependencyKey { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { - fileprivate static var ckRecord: Self { - Self("CKRecord", argumentCount: 2) { arguments in - guard - let recordType = String.fromDatabaseValue(arguments[0]), - let recordName = String.fromDatabaseValue(arguments[1]) - else { - return nil - } - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - CKRecord(recordType: recordType, recordID: CKRecord.ID(recordName: recordName)) - .encodeSystemFields(with: archiver) - return archiver.encodedData - } - } - fileprivate static var didInsert: Self { Self("didInsert") { @Dependency(\.defaultSyncEngine) var defaultSyncEngine From cd7e450d356119e54ea4bfffc1e4e6efbe51d4a9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 15 May 2025 16:56:09 -0700 Subject: [PATCH 025/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index bb42aeb8..e9bc6467 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -393,14 +393,44 @@ extension SyncEngine: CKSyncEngineDelegate { } for savedRecord in event.savedRecords { - let existingRecord = withErrorReporting { - try database.read { db in - try Record.where { - $0.zoneName.eq(savedRecord.recordID.zoneID.zoneName) - && $0.recordName.eq(savedRecord.recordID.recordName) + let query = Record.where { + $0.zoneName.eq(savedRecord.recordID.zoneID.zoneName) + && $0.recordName.eq(savedRecord.recordID.recordName) + } + let lastKnownServerRecord = + withErrorReporting { + try database.read { db in + try query + .select(\.lastKnownServerRecord) + .fetchOne(db) } - .fetchOne(db) + ?? nil + } + ?? nil + + let localRecord = + lastKnownServerRecord + ?? CKRecord( + recordType: savedRecord.recordID.zoneID.zoneName, + recordID: savedRecord.recordID + ) + + func updateLastKnownServerRecord() { + withErrorReporting { + try database.write { db in + try query + .update { $0.lastKnownServerRecord = savedRecord } + .execute(db) + } + } + } + + if let lastKnownDate = localRecord.modificationDate { + if let savedDate = savedRecord.modificationDate, lastKnownDate < savedDate { + updateLastKnownServerRecord() } + } else { + updateLastKnownServerRecord() } } @@ -456,7 +486,7 @@ extension DatabaseFunction { } } -fileprivate struct Trigger { +private struct Trigger { typealias QueryValue = Void let function: DatabaseFunction From fb1ee633a144eaf4324748cd6ec02da2eba6c47d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 09:10:44 -0700 Subject: [PATCH 026/581] wip --- Sources/SharingGRDBCore/CloudKit/Record.swift | 7 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 181 ++++++++++++++---- 2 files changed, 149 insertions(+), 39 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Record.swift b/Sources/SharingGRDBCore/CloudKit/Record.swift index 87e89ce6..32c4cf34 100644 --- a/Sources/SharingGRDBCore/CloudKit/Record.swift +++ b/Sources/SharingGRDBCore/CloudKit/Record.swift @@ -43,6 +43,13 @@ struct Record { // @Column(as: CKRecord?.DataRepresentation.self) var lastKnownServerRecord: CKRecord? var localModificationDate: Date + + static func `for`(_ record: CKRecord) -> Where { + Self.where { + $0.zoneName.eq(record.recordID.zoneID.zoneName) + && $0.recordName.eq(record.recordID.recordName) + } + } } // NB: This is generated by inlining the above macro applications. diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index e9bc6467..1057bcbe 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -285,6 +285,7 @@ public final actor SyncEngine { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + logger.debug("handleEvent: \(event)") switch event { case .accountChange(let event): handleAccountChange(event) @@ -323,7 +324,6 @@ extension SyncEngine: CKSyncEngineDelegate { } private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { - logger.debug("handleAccountChange: \(event)") switch event.changeType { case .signIn: for table in tables.values { @@ -332,12 +332,10 @@ extension SyncEngine: CKSyncEngineDelegate { ) withErrorReporting(.sharingGRDBCloudKitFailure) { let names: [String] = try database.read { db in - func open(_ table: T.Type) throws -> [String] { - try SQLQueryExpression( - "SELECT \(table.columns.primaryKey) FROM \(table)", - as: String.self - ) - .fetchAll(db) + func open(_: T.Type) throws -> [String] { + try T + .select { SQLQueryExpression("\($0.primaryKey)", as: String.self) } + .fetchAll(db) } return try open(table) } @@ -382,6 +380,36 @@ extension SyncEngine: CKSyncEngineDelegate { private func handleFetchedRecordZoneChanges( _ event: CKSyncEngine.Event.FetchedRecordZoneChanges ) { + for modification in event.modifications { + mergeFromServerRecord(modification.record) + refreshLastKnownServerRecord(modification.record) + } + + for deletion in event.deletions { + if let table = tables[deletion.recordID.zoneID.zoneName] { + func open(_: T.Type) { + withErrorReporting(.sharingGRDBCloudKitFailure) { + try database.write { db in + try T + .where { + SQLQueryExpression("\($0.primaryKey) = \(bind: deletion.recordID.recordName)") + } + .delete() + .execute(db) + } + } + } + open(table) + } else { + reportIssue( + .sharingGRDBCloudKitFailure.appending( + """ + : No table to delete from: "\(deletion.recordID.zoneID.zoneName)" + """ + ) + ) + } + } } private func handleSentRecordZoneChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) { @@ -393,48 +421,111 @@ extension SyncEngine: CKSyncEngineDelegate { } for savedRecord in event.savedRecords { - let query = Record.where { - $0.zoneName.eq(savedRecord.recordID.zoneID.zoneName) - && $0.recordName.eq(savedRecord.recordID.recordName) + refreshLastKnownServerRecord(savedRecord) + } + + for failedRecordSave in event.failedRecordSaves { + } + } + + private func mergeFromServerRecord(_ record: CKRecord) { + withErrorReporting(.sharingGRDBCloudKitFailure) { + let localModificationDate = try database.read { db in + try Record.for(record).select(\.localModificationDate).fetchOne(db) } - let lastKnownServerRecord = - withErrorReporting { - try database.read { db in - try query - .select(\.lastKnownServerRecord) - .fetchOne(db) - } - ?? nil + guard let table = tables[record.recordID.zoneID.zoneName] + else { + reportIssue( + .sharingGRDBCloudKitFailure.appending( + """ + : No table to merge from: "\(record.recordID.zoneID.zoneName)" + """ + ) + ) + return + } + guard + let localModificationDate, + localModificationDate > record.modificationDate ?? .distantPast + else { + let columnNames = try database.read { db in + try SQLQueryExpression( + """ + SELECT "name" + FROM pragma_table_info(\(bind: table.tableName)) + """, + as: String.self + ) + .fetchAll(db) } - ?? nil - - let localRecord = - lastKnownServerRecord - ?? CKRecord( - recordType: savedRecord.recordID.zoneID.zoneName, - recordID: savedRecord.recordID + var query: QueryFragment = "INSERT INTO \(table) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + let encryptedValues = record.encryptedValues + query.append( + columnNames + .map { columnName in + encryptedValues[columnName]?.queryFragment ?? "NULL" + } + .joined(separator: ", ") + ) + func open(_: T.Type) { + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET") + } + open(table) + query.append( + columnNames + .map { + """ + \(quote: $0) = "excluded".\(quote: $0) + """ + } + .joined(separator: ",") ) + try database.write { db in + try SQLQueryExpression(query).execute(db) + } + return + } + } + } - func updateLastKnownServerRecord() { - withErrorReporting { - try database.write { db in - try query - .update { $0.lastKnownServerRecord = savedRecord } - .execute(db) - } + private func refreshLastKnownServerRecord(_ record: CKRecord) { + let query = Record.for(record) + let lastKnownServerRecord = + withErrorReporting(.sharingGRDBCloudKitFailure) { + try database.read { db in + try query + .select(\.lastKnownServerRecord) + .fetchOne(db) } + ?? nil } + ?? nil + + let localRecord = + lastKnownServerRecord + ?? CKRecord( + recordType: record.recordID.zoneID.zoneName, + recordID: record.recordID + ) - if let lastKnownDate = localRecord.modificationDate { - if let savedDate = savedRecord.modificationDate, lastKnownDate < savedDate { - updateLastKnownServerRecord() + func updateLastKnownServerRecord() { + withErrorReporting(.sharingGRDBCloudKitFailure) { + try database.write { db in + try query + .update { $0.lastKnownServerRecord = record } + .execute(db) } - } else { - updateLastKnownServerRecord() } } - for failedRecordSave in event.failedRecordSaves { + if let lastKnownDate = localRecord.modificationDate { + if let recordDate = record.modificationDate, lastKnownDate < recordDate { + updateLastKnownServerRecord() + } + } else { + updateLastKnownServerRecord() } } } @@ -469,7 +560,7 @@ extension DatabaseFunction { } } - fileprivate convenience init( + private convenience init( _ name: String, function: @escaping @Sendable (String, String) async -> Void ) { @@ -531,6 +622,18 @@ private struct Trigger { } } +extension __CKRecordObjCValue { + fileprivate var queryFragment: QueryFragment { + if let queryBindable = self as? any QueryBindable { + return queryBindable.queryFragment + } else { + return "\(.invalid(Unbindable()))" + } + } +} + +private struct Unbindable: Error {} + extension String { fileprivate static let sharingGRDBCloudKitSchemaName = "sharing_grdb_icloud" fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" From 15f4ab0267e455115b660a6692bafaafdca7a36b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 10:11:49 -0700 Subject: [PATCH 027/581] wip --- .../xcshareddata/swiftpm/Package.resolved | 4 +- Sources/SharingGRDB/Exports.swift | 1 + Sources/SharingGRDBCore/CloudKit/Record.swift | 39 ++---- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 117 ++++++++++++------ Sources/SharingGRDBCore/CloudKitOld.swift | 2 +- 5 files changed, 88 insertions(+), 75 deletions(-) diff --git a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 94b17ac1..42a58f05 100644 --- a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "33215ac6966f7a6274a68634cf84cde00e0f1f2e3dcef3f092d9e446837c79b4", + "originHash" : "9c071e96c29584bec134f4f8a5f344125ef801fc238a2c7f9a67c8eb135180d9", "pins" : [ { "identity" : "combine-schedulers", @@ -142,7 +142,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "default-date-uuid-representations", - "revision" : "e56c2aecbf1da086ddc076c9d5a916cb4614815f" + "revision" : "67ca68b8eb8375a8699d9adf8aef438fc55fef4a" } }, { diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift index 07a5c659..103bfe18 100644 --- a/Sources/SharingGRDB/Exports.swift +++ b/Sources/SharingGRDB/Exports.swift @@ -1,2 +1,3 @@ @_exported import SharingGRDBCore @_exported import StructuredQueriesGRDB + diff --git a/Sources/SharingGRDBCore/CloudKit/Record.swift b/Sources/SharingGRDBCore/CloudKit/Record.swift index 32c4cf34..d04c9f33 100644 --- a/Sources/SharingGRDBCore/CloudKit/Record.swift +++ b/Sources/SharingGRDBCore/CloudKit/Record.swift @@ -42,14 +42,7 @@ struct Record { var recordName: String // @Column(as: CKRecord?.DataRepresentation.self) var lastKnownServerRecord: CKRecord? - var localModificationDate: Date - - static func `for`(_ record: CKRecord) -> Where { - Self.where { - $0.zoneName.eq(record.recordID.zoneID.zoneName) - && $0.recordName.eq(record.recordID.recordName) - } - } + var localModificationDate: Date? } // NB: This is generated by inlining the above macro applications. @@ -57,26 +50,12 @@ struct Record { extension Record: StructuredQueriesCore.Table { public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Record - public let zoneName = StructuredQueriesCore.TableColumn( - "zoneName", - keyPath: \QueryValue.zoneName - ) - public let recordName = StructuredQueriesCore.TableColumn( - "recordName", - keyPath: \QueryValue.recordName - ) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord?.DataRepresentation - >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let localModificationDate = StructuredQueriesCore.TableColumn( - "localModificationDate", - keyPath: \QueryValue.localModificationDate - ) + public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) + public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let localModificationDate = StructuredQueriesCore.TableColumn("localModificationDate", keyPath: \QueryValue.localModificationDate) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [ - QueryValue.columns.zoneName, QueryValue.columns.recordName, - QueryValue.columns.lastKnownServerRecord, QueryValue.columns.localModificationDate, - ] + [QueryValue.columns.zoneName, QueryValue.columns.recordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.localModificationDate] } } public static let columns = TableColumns() @@ -85,7 +64,7 @@ extension Record: StructuredQueriesCore.Table { let zoneName = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) - let localModificationDate = try decoder.decode(Date.self) + self.localModificationDate = try decoder.decode(Date.self) guard let zoneName else { throw QueryDecodingError.missingRequiredColumn } @@ -95,12 +74,8 @@ extension Record: StructuredQueriesCore.Table { guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } - guard let localModificationDate else { - throw QueryDecodingError.missingRequiredColumn - } self.zoneName = zoneName self.recordName = recordName self.lastKnownServerRecord = lastKnownServerRecord - self.localModificationDate = localModificationDate } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 1057bcbe..b6ff1ef7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -14,6 +14,7 @@ public final actor SyncEngine { nonisolated let container: CKContainer nonisolated let database: any DatabaseWriter nonisolated let tables: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + lazy var metadatabase: any DatabaseWriter = try! DatabaseQueue() var stateSerialization: CKSyncEngine.State.Serialization? lazy var underlyingSyncEngine: CKSyncEngine = defaultSyncEngine @@ -33,22 +34,7 @@ public final actor SyncEngine { } func setUpSyncEngine() throws { - let metadatabaseURL = try URL.metadatabase(container: container) - var configuration = Configuration() - configuration.prepareDatabase { db in - db.trace { - logger.debug("\($0.expandedDescription)") - } - } - let metadatabase = try DatabaseQueue( - path: metadatabaseURL.path(percentEncoded: false), - configuration: configuration - ) - logger.info( - """ - open "\(metadatabaseURL.path(percentEncoded: false))" - """ - ) + metadatabase = try defaultMetadatabase var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true @@ -60,7 +46,7 @@ public final actor SyncEngine { "zoneName" TEXT NOT NULL, "recordName" TEXT NOT NULL, "lastKnownServerRecord" BLOB, - "localModificationDate" TEXT NOT NULL, + "localModificationDate" TEXT, PRIMARY KEY("zoneName", "recordName") ) STRICT """ @@ -156,13 +142,9 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_localModifications" AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Record.self) - ("zoneName", "recordName", "localModificationDate") + ("zoneName", "recordName") VALUES - ( - '\(raw: table.tableName)', - "new".\(quote: T.columns.primaryKey.name), - datetime('subsec') - ) + ('\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name)) ON CONFLICT("zoneName", "recordName") DO UPDATE SET "localModificationDate" = "excluded"."localModificationDate"; END @@ -204,7 +186,6 @@ public final actor SyncEngine { db.remove(function: .didUpdate) db.remove(function: .didInsert) } - let metadatabaseURL = try URL.metadatabase(container: container) try database.write { db in try SQLQueryExpression( "DETACH DATABASE \(quote: .sharingGRDBCloudKitSchemaName)" @@ -271,6 +252,34 @@ public final actor SyncEngine { ) } + private var metadatabaseURL: URL { + URL.metadatabase(container: container) + } + + private var defaultMetadatabase: any DatabaseWriter { + get throws { + var configuration = Configuration() + configuration.prepareDatabase { db in + db.trace { + logger.debug("\($0.expandedDescription)") + } + } + logger.info( + """ + open "\(self.metadatabaseURL.path(percentEncoded: false))" + """ + ) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + return try DatabaseQueue( + path: metadatabaseURL.path(percentEncoded: false), + configuration: configuration + ) + } + } + private var defaultSyncEngine: CKSyncEngine { CKSyncEngine( CKSyncEngine.Configuration( @@ -403,9 +412,9 @@ extension SyncEngine: CKSyncEngineDelegate { } else { reportIssue( .sharingGRDBCloudKitFailure.appending( - """ - : No table to delete from: "\(deletion.recordID.zoneID.zoneName)" - """ + """ + : No table to delete from: "\(deletion.recordID.zoneID.zoneName)" + """ ) ) } @@ -430,9 +439,10 @@ extension SyncEngine: CKSyncEngineDelegate { private func mergeFromServerRecord(_ record: CKRecord) { withErrorReporting(.sharingGRDBCloudKitFailure) { - let localModificationDate = try database.read { db in - try Record.for(record).select(\.localModificationDate).fetchOne(db) + let localModificationDate = try metadatabase.read { db in + try Record.for(record.recordID).select(\.localModificationDate).fetchOne(db) } + ?? nil guard let table = tables[record.recordID.zoneID.zoneName] else { reportIssue( @@ -483,7 +493,9 @@ extension SyncEngine: CKSyncEngineDelegate { .joined(separator: ",") ) try database.write { db in - try SQLQueryExpression(query).execute(db) + try $areTriggersEnabled.withValue(false) { + try SQLQueryExpression(query).execute(db) + } } return } @@ -491,10 +503,10 @@ extension SyncEngine: CKSyncEngineDelegate { } private func refreshLastKnownServerRecord(_ record: CKRecord) { - let query = Record.for(record) + let query = Record.for(record.recordID) let lastKnownServerRecord = withErrorReporting(.sharingGRDBCloudKitFailure) { - try database.read { db in + try metadatabase.read { db in try query .select(\.lastKnownServerRecord) .fetchOne(db) @@ -541,6 +553,9 @@ extension SyncEngine: TestDependencyKey { extension DatabaseFunction { fileprivate static var didInsert: Self { Self("didInsert") { + guard areTriggersEnabled else { + return + } @Dependency(\.defaultSyncEngine) var defaultSyncEngine await defaultSyncEngine.didInsert(recordName: $0, zoneName: $1) } @@ -548,6 +563,9 @@ extension DatabaseFunction { fileprivate static var didUpdate: Self { Self("didUpdate") { + guard areTriggersEnabled else { + return + } @Dependency(\.defaultSyncEngine) var defaultSyncEngine await defaultSyncEngine.didUpdate(recordName: $0, zoneName: $1) } @@ -555,6 +573,9 @@ extension DatabaseFunction { fileprivate static var willDelete: Self { Self("willDelete") { + guard areTriggersEnabled else { + return + } @Dependency(\.defaultSyncEngine) var defaultSyncEngine await defaultSyncEngine.willDelete(recordName: $0, zoneName: $1) } @@ -577,6 +598,8 @@ extension DatabaseFunction { } } +@TaskLocal fileprivate var areTriggersEnabled = true + private struct Trigger { typealias QueryValue = Void @@ -624,8 +647,16 @@ private struct Trigger { extension __CKRecordObjCValue { fileprivate var queryFragment: QueryFragment { - if let queryBindable = self as? any QueryBindable { - return queryBindable.queryFragment + if let value = self as? Int64 { + return value.queryFragment + } else if let value = self as? Double { + return value.queryFragment + } else if let value = self as? String { + return value.queryFragment + } else if let value = self as? Data { + return value.queryFragment + } else if let value = self as? Date { + return value.queryFragment } else { return "\(.invalid(Unbindable()))" } @@ -634,6 +665,16 @@ extension __CKRecordObjCValue { private struct Unbindable: Error {} +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Record { + static func `for`(_ recordID: CKRecord.ID) -> Where { + Self.where { + $0.zoneName.eq(recordID.zoneID.zoneName) + && $0.recordName.eq(recordID.recordName) + } + } +} + extension String { fileprivate static let sharingGRDBCloudKitSchemaName = "sharing_grdb_icloud" fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" @@ -641,12 +682,8 @@ extension String { @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) extension URL { - fileprivate static func metadatabase(container: CKContainer) throws -> Self { - try FileManager.default.createDirectory( - at: applicationSupportDirectory, - withIntermediateDirectories: true - ) - return applicationSupportDirectory.appending( + fileprivate static func metadatabase(container: CKContainer) -> Self { + applicationSupportDirectory.appending( component: "\(container.containerIdentifier.map { "\($0)." } ?? "")sharing-grdb-icloud.sqlite" ) } diff --git a/Sources/SharingGRDBCore/CloudKitOld.swift b/Sources/SharingGRDBCore/CloudKitOld.swift index 53d1ed42..c93288f0 100644 --- a/Sources/SharingGRDBCore/CloudKitOld.swift +++ b/Sources/SharingGRDBCore/CloudKitOld.swift @@ -537,7 +537,7 @@ \(bind: newRecord.recordID.tableName), \(bind: newRecord.recordID.primaryKey), \(archiver.encodedData), - \(bind: Date.ISO8601Representation(queryOutput: newRecord.modificationDate ?? Date())) + \(bind: Date.ISO8601Representation(queryOutput: .distantPast)) ) ON CONFLICT("tableName", "primaryKey") DO UPDATE SET "recordData" = \(archiver.encodedData) From 187d0b383c16a9d0e9b74497ec71f1eb2c102ed7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 10:15:24 -0700 Subject: [PATCH 028/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b6ff1ef7..dd02f126 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -503,29 +503,12 @@ extension SyncEngine: CKSyncEngineDelegate { } private func refreshLastKnownServerRecord(_ record: CKRecord) { - let query = Record.for(record.recordID) - let lastKnownServerRecord = - withErrorReporting(.sharingGRDBCloudKitFailure) { - try metadatabase.read { db in - try query - .select(\.lastKnownServerRecord) - .fetchOne(db) - } - ?? nil - } - ?? nil - - let localRecord = - lastKnownServerRecord - ?? CKRecord( - recordType: record.recordID.zoneID.zoneName, - recordID: record.recordID - ) + let localRecord = lastKnownServerOrLocalRecord(recordID: record.recordID) func updateLastKnownServerRecord() { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in - try query + try Record.for(record.recordID) .update { $0.lastKnownServerRecord = record } .execute(db) } @@ -540,6 +523,24 @@ extension SyncEngine: CKSyncEngineDelegate { updateLastKnownServerRecord() } } + + private func lastKnownServerOrLocalRecord(recordID: CKRecord.ID) -> CKRecord { + let lastKnownServerRecord = + withErrorReporting(.sharingGRDBCloudKitFailure) { + try metadatabase.read { db in + try Record.for(recordID) + .select(\.lastKnownServerRecord) + .fetchOne(db) + } + ?? nil + } + ?? nil + return lastKnownServerRecord + ?? CKRecord( + recordType: recordID.zoneID.zoneName, + recordID: recordID + ) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From 547e9a38256891ed3efd66ebc00451b7542920f9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 16 May 2025 14:10:24 -0500 Subject: [PATCH 029/581] wip --- Examples/Reminders/Schema.swift | 2 +- .../SharingGRDBCore/CloudKit/CKRecord.swift | 78 ++++++++++ Sources/SharingGRDBCore/CloudKit/Record.swift | 35 ----- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 147 ++++++++++++------ 4 files changed, 179 insertions(+), 83 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/CKRecord.swift diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 91073375..df5e2961 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -234,7 +234,7 @@ func appDatabase() throws -> any DatabaseWriter { } #if DEBUG && targetEnvironment(simulator) - if context != .test { + if context == .preview { migrator.registerMigration("Seed sample data") { db in try db.seedSampleData() } diff --git a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift new file mode 100644 index 00000000..35d893a8 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift @@ -0,0 +1,78 @@ +import CloudKit +import StructuredQueriesCore + +extension CKRecord { + struct DataRepresentation: QueryBindable, QueryRepresentable { + let queryOutput: CKRecord + + var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encodeSystemFields(with: archiver) + return archiver.encodedData.queryBinding + } + + init(queryOutput: CKRecord) { + self.queryOutput = queryOutput + } + + init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + guard let data = try Data?(decoder: &decoder) else { + throw QueryDecodingError.missingRequiredColumn + } + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = CKRecord(coder: coder) else { + throw DecodingError() + } + self.init(queryOutput: queryOutput) + } + + private struct DecodingError: Error {} + } +} + +extension CKRecord? { + typealias DataRepresentation = CKRecord.DataRepresentation? +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +extension CKRecord { + func update(with row: T, userModificationDate: Date?) { + encryptedValues[Self.userModificationDateKey] = userModificationDate + for column in T.TableColumns.allColumns { + func open(_ column: some TableColumnExpression) { + let column = column as! any TableColumnExpression + let value = Value.init(queryOutput: row[keyPath: column.keyPath]) + switch value.queryBinding { + case .blob(let value): + encryptedValues[column.name] = Data(value) + case .double(let value): + encryptedValues[column.name] = value + case .date(let value): + encryptedValues[column.name] = value + case .int(let value): + encryptedValues[column.name] = value + case .null: + encryptedValues[column.name] = nil + case .text(let value): + encryptedValues[column.name] = value + case .uuid(let value): + encryptedValues[column.name] = value.uuidString.lowercased() + case .invalid(let error): + reportIssue(error) + } + } + open(column) + } + } + + private static let userModificationDateKey = "sharing_grdb_cloudkit_userModificationDate" +} + +extension PrimaryKeyedTable { + static func find(recordID: CKRecord.ID) -> Where { + Self.where { + SQLQueryExpression("\($0.primaryKey) = \(bind: recordID.recordName)") + } + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/Record.swift b/Sources/SharingGRDBCore/CloudKit/Record.swift index d04c9f33..eda40d20 100644 --- a/Sources/SharingGRDBCore/CloudKit/Record.swift +++ b/Sources/SharingGRDBCore/CloudKit/Record.swift @@ -1,39 +1,4 @@ import CloudKit -import Foundation - -extension CKRecord { - struct DataRepresentation: QueryBindable, QueryRepresentable { - let queryOutput: CKRecord - - var queryBinding: QueryBinding { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - queryOutput.encodeSystemFields(with: archiver) - return archiver.encodedData.queryBinding - } - - init(queryOutput: CKRecord) { - self.queryOutput = queryOutput - } - - init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - guard let data = try Data?(decoder: &decoder) else { - throw QueryDecodingError.missingRequiredColumn - } - let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true - guard let queryOutput = CKRecord(coder: coder) else { - throw DecodingError() - } - self.init(queryOutput: queryOutput) - } - - private struct DecodingError: Error {} - } -} - -extension CKRecord? { - typealias DataRepresentation = CKRecord.DataRepresentation? -} @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) // @Table("sharing_grdb_cloudkit_records") diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index dd02f126..7ca7eea8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -23,6 +23,9 @@ public final actor SyncEngine { database: any DatabaseWriter, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { + if database.configuration.foreignKeysEnabled { + reportIssue("TODO: better messaging") + } self.container = container self.database = database self.tables = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) @@ -34,6 +37,8 @@ public final actor SyncEngine { } func setUpSyncEngine() throws { + defer { underlyingSyncEngine = defaultSyncEngine } + metadatabase = try defaultMetadatabase var migrator = DatabaseMigrator() #if DEBUG @@ -72,11 +77,9 @@ public final actor SyncEngine { .execute(db) } try migrator.migrate(metadatabase) - stateSerialization = try metadatabase.read { db in - try StateSerialization.all.fetchOne(db)?.data - } let previousZones = try metadatabase.read { db in - try Zone.all.fetchAll(db) + stateSerialization = try StateSerialization.all.fetchOne(db)?.data + return try Zone.all.fetchAll(db) } let currentZones = try database.read { db in try SQLQueryExpression( @@ -99,6 +102,7 @@ public final actor SyncEngine { return existingZone.schema != currentZone.schema } if !zonesToFetch.isEmpty { + // TODO: could this be removed if setUpSyncEngine was async? Task { await withErrorReporting(.sharingGRDBCloudKitFailure) { try await underlyingSyncEngine.fetchChanges( @@ -119,6 +123,7 @@ public final actor SyncEngine { "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" ) .execute(db) + db.add(function: .areTriggersEnabled) db.add(function: .didInsert) db.add(function: .didUpdate) db.add(function: .willDelete) @@ -139,14 +144,28 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_localModifications" + "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" + AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN + INSERT INTO \(Record.self) + ("zoneName", "recordName", "localModificationDate") + SELECT '\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name), datetime('subsec') + WHERE areTriggersEnabled() + ON CONFLICT("zoneName", "recordName") DO NOTHING; + END + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Record.self) ("zoneName", "recordName") - VALUES - ('\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name)) + SELECT '\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name) + WHERE areTriggersEnabled() ON CONFLICT("zoneName", "recordName") DO UPDATE SET - "localModificationDate" = "excluded"."localModificationDate"; + "localModificationDate" = datetime('subsec'); END """ ) @@ -162,7 +181,13 @@ public final actor SyncEngine { for table in tables.values { try SQLQueryExpression( """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_localModifications" + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_metadataUpdates" + """ + ) + .execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_metadataInserts" """ ) .execute(db) @@ -185,6 +210,7 @@ public final actor SyncEngine { db.remove(function: .willDelete) db.remove(function: .didUpdate) db.remove(function: .didInsert) + db.remove(function: .areTriggersEnabled) } try database.write { db in try SQLQueryExpression( @@ -329,7 +355,44 @@ extension SyncEngine: CKSyncEngineDelegate { syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { logger.debug("nextRecordZoneChangeBatch: \(context)") - return nil + + let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) + let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in + let record = await lastKnownServerRecord(recordID: recordID) + guard let table = tables[recordID.zoneID.zoneName] + else { + reportIssue("") + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil + } + func open(_ table: T.Type) async -> CKRecord? { + let row = + withErrorReporting { + try database.read { db in + try T.find(recordID: recordID).fetchOne(db) + } + } + ?? nil + guard let row + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil + } + + let ckRecord = record?.lastKnownServerRecord ?? CKRecord( + recordType: recordID.zoneID.zoneName, + recordID: recordID + ) + ckRecord.update( + with: T(queryOutput: row), + userModificationDate: record?.localModificationDate + ) + await refreshLastKnownServerRecord(ckRecord) + return ckRecord + } + return await open(table) + } + return batch } private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { @@ -399,10 +462,7 @@ extension SyncEngine: CKSyncEngineDelegate { func open(_: T.Type) { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in - try T - .where { - SQLQueryExpression("\($0.primaryKey) = \(bind: deletion.recordID.recordName)") - } + try T.find(recordID: deletion.recordID) .delete() .execute(db) } @@ -439,10 +499,11 @@ extension SyncEngine: CKSyncEngineDelegate { private func mergeFromServerRecord(_ record: CKRecord) { withErrorReporting(.sharingGRDBCloudKitFailure) { - let localModificationDate = try metadatabase.read { db in - try Record.for(record.recordID).select(\.localModificationDate).fetchOne(db) - } - ?? nil + let localModificationDate = + try metadatabase.read { db in + try Record.for(record.recordID).select(\.localModificationDate).fetchOne(db) + } + ?? nil guard let table = tables[record.recordID.zoneID.zoneName] else { reportIssue( @@ -454,6 +515,7 @@ extension SyncEngine: CKSyncEngineDelegate { ) return } + // TODO: should be use userModificationDate here instead of record.modificationDate guard let localModificationDate, localModificationDate > record.modificationDate ?? .distantPast @@ -503,7 +565,7 @@ extension SyncEngine: CKSyncEngineDelegate { } private func refreshLastKnownServerRecord(_ record: CKRecord) { - let localRecord = lastKnownServerOrLocalRecord(recordID: record.recordID) + let localRecord = lastKnownServerRecord(recordID: record.recordID) func updateLastKnownServerRecord() { withErrorReporting(.sharingGRDBCloudKitFailure) { @@ -515,7 +577,7 @@ extension SyncEngine: CKSyncEngineDelegate { } } - if let lastKnownDate = localRecord.modificationDate { + if let lastKnownDate = localRecord?.lastKnownServerRecord?.modificationDate { if let recordDate = record.modificationDate, lastKnownDate < recordDate { updateLastKnownServerRecord() } @@ -524,22 +586,15 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func lastKnownServerOrLocalRecord(recordID: CKRecord.ID) -> CKRecord { - let lastKnownServerRecord = - withErrorReporting(.sharingGRDBCloudKitFailure) { - try metadatabase.read { db in - try Record.for(recordID) - .select(\.lastKnownServerRecord) - .fetchOne(db) - } - ?? nil + private func lastKnownServerRecord(recordID: CKRecord.ID) -> Record? { + + withErrorReporting(.sharingGRDBCloudKitFailure) { + try metadatabase.read { db in + try Record.for(recordID) + .fetchOne(db) } + } ?? nil - return lastKnownServerRecord - ?? CKRecord( - recordType: recordID.zoneID.zoneName, - recordID: recordID - ) } } @@ -554,9 +609,6 @@ extension SyncEngine: TestDependencyKey { extension DatabaseFunction { fileprivate static var didInsert: Self { Self("didInsert") { - guard areTriggersEnabled else { - return - } @Dependency(\.defaultSyncEngine) var defaultSyncEngine await defaultSyncEngine.didInsert(recordName: $0, zoneName: $1) } @@ -564,9 +616,6 @@ extension DatabaseFunction { fileprivate static var didUpdate: Self { Self("didUpdate") { - guard areTriggersEnabled else { - return - } @Dependency(\.defaultSyncEngine) var defaultSyncEngine await defaultSyncEngine.didUpdate(recordName: $0, zoneName: $1) } @@ -574,14 +623,17 @@ extension DatabaseFunction { fileprivate static var willDelete: Self { Self("willDelete") { - guard areTriggersEnabled else { - return - } @Dependency(\.defaultSyncEngine) var defaultSyncEngine await defaultSyncEngine.willDelete(recordName: $0, zoneName: $1) } } + fileprivate static var areTriggersEnabled: Self { + Self("areTriggersEnabled", argumentCount: 0) { _ in + SharingGRDBCore.areTriggersEnabled + } + } + private convenience init( _ name: String, function: @escaping @Sendable (String, String) async -> Void @@ -599,7 +651,7 @@ extension DatabaseFunction { } } -@TaskLocal fileprivate var areTriggersEnabled = true +@TaskLocal private var areTriggersEnabled = true private struct Trigger { typealias QueryValue = Void @@ -623,9 +675,10 @@ private struct Trigger { CREATE TEMPORARY TRIGGER \(name) \(when.rawValue) \(operation.rawValue) ON \(quote: Base.tableName) FOR EACH ROW BEGIN SELECT \(raw: function.name)( - \(quote: Base.tableName, delimiter: .text), - \(quote: operation == .delete ? "old" : "new").\(quote: Base.columns.primaryKey.name) - ); + \(quote: operation == .delete ? "old" : "new").\(quote: Base.columns.primaryKey.name), + \(quote: Base.tableName, delimiter: .text) + ) + WHERE areTriggersEnabled(); END """ } From 47cfa3d8e8307ffa717f65b88be9ebbe856e13dc Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 12:21:14 -0700 Subject: [PATCH 030/581] wip --- .../SharingGRDBCore/CloudKit/CKRecord.swift | 9 +++++++-- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 17 ++++++++--------- Tests/SharingGRDBTests/FetchTests.swift | 18 +----------------- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift index 35d893a8..0e9d3237 100644 --- a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift +++ b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift @@ -38,11 +38,11 @@ extension CKRecord? { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension CKRecord { func update(with row: T, userModificationDate: Date?) { - encryptedValues[Self.userModificationDateKey] = userModificationDate + self.userModificationDate = userModificationDate for column in T.TableColumns.allColumns { func open(_ column: some TableColumnExpression) { let column = column as! any TableColumnExpression - let value = Value.init(queryOutput: row[keyPath: column.keyPath]) + let value = Value(queryOutput: row[keyPath: column.keyPath]) switch value.queryBinding { case .blob(let value): encryptedValues[column.name] = Data(value) @@ -66,6 +66,11 @@ extension CKRecord { } } + var userModificationDate: Date? { + get { encryptedValues[Self.userModificationDateKey] as? Date } + set { encryptedValues[Self.userModificationDateKey] = newValue } + } + private static let userModificationDateKey = "sharing_grdb_cloudkit_userModificationDate" } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 7ca7eea8..12030bd2 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -379,10 +379,12 @@ extension SyncEngine: CKSyncEngineDelegate { return nil } - let ckRecord = record?.lastKnownServerRecord ?? CKRecord( - recordType: recordID.zoneID.zoneName, - recordID: recordID - ) + let ckRecord = + record?.lastKnownServerRecord + ?? CKRecord( + recordType: recordID.zoneID.zoneName, + recordID: recordID + ) ckRecord.update( with: T(queryOutput: row), userModificationDate: record?.localModificationDate @@ -515,10 +517,9 @@ extension SyncEngine: CKSyncEngineDelegate { ) return } - // TODO: should be use userModificationDate here instead of record.modificationDate guard let localModificationDate, - localModificationDate > record.modificationDate ?? .distantPast + localModificationDate > record.userModificationDate ?? .distantPast else { let columnNames = try database.read { db in try SQLQueryExpression( @@ -587,11 +588,9 @@ extension SyncEngine: CKSyncEngineDelegate { } private func lastKnownServerRecord(recordID: CKRecord.ID) -> Record? { - withErrorReporting(.sharingGRDBCloudKitFailure) { try metadatabase.read { db in - try Record.for(recordID) - .fetchOne(db) + try Record.for(recordID).fetchOne(db) } } ?? nil diff --git a/Tests/SharingGRDBTests/FetchTests.swift b/Tests/SharingGRDBTests/FetchTests.swift index 92bb0382..1aff3cc4 100644 --- a/Tests/SharingGRDBTests/FetchTests.swift +++ b/Tests/SharingGRDBTests/FetchTests.swift @@ -31,6 +31,7 @@ struct FetchTests { private struct Record: Equatable { let id: Int } + extension DatabaseWriter where Self == DatabaseQueue { fileprivate static func database() throws -> DatabaseQueue { let database = try DatabaseQueue() @@ -50,20 +51,3 @@ extension DatabaseWriter where Self == DatabaseQueue { return database } } - -@Test(.dependency(\.defaultDatabase, try .database())) func foo() throws { - @Dependency(\.defaultDatabase) var defaultDatabase - try defaultDatabase.write { db in - db.add(function: DatabaseFunction.init("didInsert", function: { arguments in - return 0 - })) - } - - try defaultDatabase.write { db in - try #sql(""" - select didInsert(1) - """) - .execute(db) - } -} -import Foundation From 5e2e1768edebdda5e0d5fd418812ed100c40b5d2 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 12:22:10 -0700 Subject: [PATCH 031/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 12030bd2..41356a34 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -148,7 +148,10 @@ public final actor SyncEngine { AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Record.self) ("zoneName", "recordName", "localModificationDate") - SELECT '\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name), datetime('subsec') + SELECT + '\(raw: table.tableName)', + "new".\(quote: T.columns.primaryKey.name), + datetime('subsec') WHERE areTriggersEnabled() ON CONFLICT("zoneName", "recordName") DO NOTHING; END @@ -321,6 +324,7 @@ public final actor SyncEngine { extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { logger.debug("handleEvent: \(event)") + switch event { case .accountChange(let event): handleAccountChange(event) From fb4b840944726d7920f1e77ca7b8b8354d5ca713 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 12:23:44 -0700 Subject: [PATCH 032/581] wip --- .../CloudKit/{Record.swift => Metadata.swift} | 34 +++++++++++++------ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 16 ++++----- 2 files changed, 32 insertions(+), 18 deletions(-) rename Sources/SharingGRDBCore/CloudKit/{Record.swift => Metadata.swift} (66%) diff --git a/Sources/SharingGRDBCore/CloudKit/Record.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift similarity index 66% rename from Sources/SharingGRDBCore/CloudKit/Record.swift rename to Sources/SharingGRDBCore/CloudKit/Metadata.swift index eda40d20..a5235473 100644 --- a/Sources/SharingGRDBCore/CloudKit/Record.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -1,8 +1,8 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Table("sharing_grdb_cloudkit_records") -struct Record { +// @Table("sharing_grdb_cloudkit_metadata") +struct Metadata { var zoneName: String var recordName: String // @Column(as: CKRecord?.DataRepresentation.self) @@ -12,19 +12,33 @@ struct Record { // NB: This is generated by inlining the above macro applications. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Record: StructuredQueriesCore.Table { +extension Metadata: StructuredQueriesCore.Table { public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = Record - public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let localModificationDate = StructuredQueriesCore.TableColumn("localModificationDate", keyPath: \QueryValue.localModificationDate) + public typealias QueryValue = Metadata + public let zoneName = StructuredQueriesCore.TableColumn( + "zoneName", + keyPath: \QueryValue.zoneName + ) + public let recordName = StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.DataRepresentation + >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let localModificationDate = StructuredQueriesCore.TableColumn( + "localModificationDate", + keyPath: \QueryValue.localModificationDate + ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.zoneName, QueryValue.columns.recordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.localModificationDate] + [ + QueryValue.columns.zoneName, QueryValue.columns.recordName, + QueryValue.columns.lastKnownServerRecord, QueryValue.columns.localModificationDate, + ] } } public static let columns = TableColumns() - public static let tableName = "sharing_grdb_cloudkit_records" + public static let tableName = "sharing_grdb_cloudkit_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let zoneName = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 41356a34..093a5802 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -47,7 +47,7 @@ public final actor SyncEngine { migrator.registerMigration("Create Metadata Tables") { db in try SQLQueryExpression( """ - CREATE TABLE "sharing_grdb_cloudkit_records" ( + CREATE TABLE "sharing_grdb_cloudkit_metadata" ( "zoneName" TEXT NOT NULL, "recordName" TEXT NOT NULL, "lastKnownServerRecord" BLOB, @@ -146,7 +146,7 @@ public final actor SyncEngine { CREATE TEMPORARY TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN - INSERT INTO \(Record.self) + INSERT INTO \(Metadata.self) ("zoneName", "recordName", "localModificationDate") SELECT '\(raw: table.tableName)', @@ -163,7 +163,7 @@ public final actor SyncEngine { CREATE TEMPORARY TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN - INSERT INTO \(Record.self) + INSERT INTO \(Metadata.self) ("zoneName", "recordName") SELECT '\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name) WHERE areTriggersEnabled() @@ -507,7 +507,7 @@ extension SyncEngine: CKSyncEngineDelegate { withErrorReporting(.sharingGRDBCloudKitFailure) { let localModificationDate = try metadatabase.read { db in - try Record.for(record.recordID).select(\.localModificationDate).fetchOne(db) + try Metadata.for(record.recordID).select(\.localModificationDate).fetchOne(db) } ?? nil guard let table = tables[record.recordID.zoneID.zoneName] @@ -575,7 +575,7 @@ extension SyncEngine: CKSyncEngineDelegate { func updateLastKnownServerRecord() { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in - try Record.for(record.recordID) + try Metadata.for(record.recordID) .update { $0.lastKnownServerRecord = record } .execute(db) } @@ -591,10 +591,10 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func lastKnownServerRecord(recordID: CKRecord.ID) -> Record? { + private func lastKnownServerRecord(recordID: CKRecord.ID) -> Metadata? { withErrorReporting(.sharingGRDBCloudKitFailure) { try metadatabase.read { db in - try Record.for(recordID).fetchOne(db) + try Metadata.for(recordID).fetchOne(db) } } ?? nil @@ -723,7 +723,7 @@ extension __CKRecordObjCValue { private struct Unbindable: Error {} @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Record { +extension Metadata { static func `for`(_ recordID: CKRecord.ID) -> Where { Self.where { $0.zoneName.eq(recordID.zoneID.zoneName) From 370433248d1c4bed5e6d2dfd73d10350c9b7513c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 13:33:25 -0700 Subject: [PATCH 033/581] wip --- Examples/Reminders/RemindersLists.swift | 6 ++-- Sources/SharingGRDB/Exports.swift | 1 - .../SharingGRDBCore/CloudKit/Metadata.swift | 12 +++---- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 33 ++++++++++--------- Sources/SharingGRDBCore/CloudKitOld.swift | 6 ++-- Sources/SharingGRDBCore/FetchAll.swift | 8 ++--- Sources/SharingGRDBCore/FetchOne.swift | 12 +++---- 7 files changed, 39 insertions(+), 39 deletions(-) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 2d6213cf..8c43f5fc 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -41,8 +41,8 @@ struct RemindersListsView: View { @State private var remindersDetailType: RemindersDetailView.DetailType? @State private var searchText = "" - @Dependency(\.cloudKitDatabase) var cloudKitDatabase @Dependency(\.defaultDatabase) private var database + @Dependency(\.defaultSyncEngine) private var syncEngine @Selection fileprivate struct ReminderListState: Identifiable { @@ -177,9 +177,7 @@ struct RemindersListsView: View { Menu { Button { Task { - await withErrorReporting { - try await cloudKitDatabase.deleteAllRecords() - } + try await syncEngine.deleteLocalData() } } label: { Text("Clear data") diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift index 103bfe18..07a5c659 100644 --- a/Sources/SharingGRDB/Exports.swift +++ b/Sources/SharingGRDB/Exports.swift @@ -1,3 +1,2 @@ @_exported import SharingGRDBCore @_exported import StructuredQueriesGRDB - diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index a5235473..9d01117c 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -7,7 +7,7 @@ struct Metadata { var recordName: String // @Column(as: CKRecord?.DataRepresentation.self) var lastKnownServerRecord: CKRecord? - var localModificationDate: Date? + var userModificationDate: Date? } // NB: This is generated by inlining the above macro applications. @@ -26,14 +26,14 @@ extension Metadata: StructuredQueriesCore.Table { public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< QueryValue, CKRecord?.DataRepresentation >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let localModificationDate = StructuredQueriesCore.TableColumn( - "localModificationDate", - keyPath: \QueryValue.localModificationDate + public let userModificationDate = StructuredQueriesCore.TableColumn( + "userModificationDate", + keyPath: \QueryValue.userModificationDate ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [ QueryValue.columns.zoneName, QueryValue.columns.recordName, - QueryValue.columns.lastKnownServerRecord, QueryValue.columns.localModificationDate, + QueryValue.columns.lastKnownServerRecord, QueryValue.columns.userModificationDate, ] } } @@ -43,7 +43,7 @@ extension Metadata: StructuredQueriesCore.Table { let zoneName = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) - self.localModificationDate = try decoder.decode(Date.self) + self.userModificationDate = try decoder.decode(Date.self) guard let zoneName else { throw QueryDecodingError.missingRequiredColumn } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 093a5802..b0629e41 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -51,7 +51,7 @@ public final actor SyncEngine { "zoneName" TEXT NOT NULL, "recordName" TEXT NOT NULL, "lastKnownServerRecord" BLOB, - "localModificationDate" TEXT, + "userModificationDate" TEXT, PRIMARY KEY("zoneName", "recordName") ) STRICT """ @@ -147,7 +147,7 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) - ("zoneName", "recordName", "localModificationDate") + ("zoneName", "recordName", "userModificationDate") SELECT '\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name), @@ -168,7 +168,7 @@ public final actor SyncEngine { SELECT '\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name) WHERE areTriggersEnabled() ON CONFLICT("zoneName", "recordName") DO UPDATE SET - "localModificationDate" = datetime('subsec'); + "userModificationDate" = datetime('subsec'); END """ ) @@ -224,7 +224,7 @@ public final actor SyncEngine { try FileManager.default.removeItem(at: metadatabaseURL) } - func deleteLocalData() throws { + public func deleteLocalData() throws { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in for table in tables.values { @@ -391,7 +391,7 @@ extension SyncEngine: CKSyncEngineDelegate { ) ckRecord.update( with: T(queryOutput: row), - userModificationDate: record?.localModificationDate + userModificationDate: record?.userModificationDate ) await refreshLastKnownServerRecord(ckRecord) return ckRecord @@ -500,14 +500,17 @@ extension SyncEngine: CKSyncEngineDelegate { } for failedRecordSave in event.failedRecordSaves { +// switch failedRecordSave.error.code { +// +// } } } private func mergeFromServerRecord(_ record: CKRecord) { withErrorReporting(.sharingGRDBCloudKitFailure) { - let localModificationDate = + let userModificationDate = try metadatabase.read { db in - try Metadata.for(record.recordID).select(\.localModificationDate).fetchOne(db) + try Metadata.for(record.recordID).select(\.userModificationDate).fetchOne(db) } ?? nil guard let table = tables[record.recordID.zoneID.zoneName] @@ -522,8 +525,8 @@ extension SyncEngine: CKSyncEngineDelegate { return } guard - let localModificationDate, - localModificationDate > record.userModificationDate ?? .distantPast + let userModificationDate, + userModificationDate > record.userModificationDate ?? .distantPast else { let columnNames = try database.read { db in try SQLQueryExpression( @@ -612,22 +615,22 @@ extension SyncEngine: TestDependencyKey { extension DatabaseFunction { fileprivate static var didInsert: Self { Self("didInsert") { - @Dependency(\.defaultSyncEngine) var defaultSyncEngine - await defaultSyncEngine.didInsert(recordName: $0, zoneName: $1) + @Dependency(\.defaultSyncEngine) var syncEngine + await syncEngine.didInsert(recordName: $0, zoneName: $1) } } fileprivate static var didUpdate: Self { Self("didUpdate") { - @Dependency(\.defaultSyncEngine) var defaultSyncEngine - await defaultSyncEngine.didUpdate(recordName: $0, zoneName: $1) + @Dependency(\.defaultSyncEngine) var syncEngine + await syncEngine.didUpdate(recordName: $0, zoneName: $1) } } fileprivate static var willDelete: Self { Self("willDelete") { - @Dependency(\.defaultSyncEngine) var defaultSyncEngine - await defaultSyncEngine.willDelete(recordName: $0, zoneName: $1) + @Dependency(\.defaultSyncEngine) var syncEngine + await syncEngine.willDelete(recordName: $0, zoneName: $1) } } diff --git a/Sources/SharingGRDBCore/CloudKitOld.swift b/Sources/SharingGRDBCore/CloudKitOld.swift index c93288f0..3b2d13d1 100644 --- a/Sources/SharingGRDBCore/CloudKitOld.swift +++ b/Sources/SharingGRDBCore/CloudKitOld.swift @@ -112,7 +112,7 @@ } func restartSyncEngine() throws { - try tearDownSyncEngine() + try tearDownSyncEngine() // setUpSyncEngine() // delete triggers @@ -705,7 +705,7 @@ """, as: PragmaForeignKey.self ) - .fetchAll(db) + .fetchAll(db) for foreignKey in foreignKeys { switch foreignKey.onDelete { case .cascade: @@ -878,7 +878,7 @@ } } - fileprivate struct Trigger { + private struct Trigger { let idColumn: String let function: String let tableName: String diff --git a/Sources/SharingGRDBCore/FetchAll.swift b/Sources/SharingGRDBCore/FetchAll.swift index 215db0de..924327a2 100644 --- a/Sources/SharingGRDBCore/FetchAll.swift +++ b/Sources/SharingGRDBCore/FetchAll.swift @@ -442,8 +442,8 @@ extension FetchAll { scheduler: some ValueObservationScheduler & Hashable ) where - Element: QueryRepresentable, - Element == S.QueryValue.QueryOutput + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput { sharedReader = SharedReader( wrappedValue: wrappedValue, @@ -739,8 +739,8 @@ extension FetchAll: Equatable where Element: Equatable { animation: Animation ) where - Element: QueryRepresentable, - Element == S.QueryValue.QueryOutput + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput { sharedReader = SharedReader( wrappedValue: wrappedValue, diff --git a/Sources/SharingGRDBCore/FetchOne.swift b/Sources/SharingGRDBCore/FetchOne.swift index d68719da..53655b79 100644 --- a/Sources/SharingGRDBCore/FetchOne.swift +++ b/Sources/SharingGRDBCore/FetchOne.swift @@ -182,8 +182,8 @@ public struct FetchOne: Sendable { database: (any DatabaseReader)? = nil ) where - Value: QueryRepresentable, - Value == S.QueryValue.QueryOutput + Value: QueryRepresentable, + Value == S.QueryValue.QueryOutput { sharedReader = SharedReader( wrappedValue: wrappedValue, @@ -424,8 +424,8 @@ extension FetchOne { scheduler: some ValueObservationScheduler & Hashable ) where - Value: QueryRepresentable, - Value == S.QueryValue.QueryOutput + Value: QueryRepresentable, + Value == S.QueryValue.QueryOutput { sharedReader = SharedReader( wrappedValue: wrappedValue, @@ -698,8 +698,8 @@ extension FetchOne: Equatable where Value: Equatable { animation: Animation ) where - Value: QueryRepresentable, - Value == S.QueryValue.QueryOutput + Value: QueryRepresentable, + Value == S.QueryValue.QueryOutput { sharedReader = SharedReader( wrappedValue: wrappedValue, From 5ceb535d4cbd72367003905a15b1ee6e9d42f803 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 13:47:10 -0700 Subject: [PATCH 034/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b0629e41..6d443085 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -23,9 +23,13 @@ public final actor SyncEngine { database: any DatabaseWriter, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { - if database.configuration.foreignKeysEnabled { - reportIssue("TODO: better messaging") - } + // TODO: Explain why / link to documentation? + precondition( + !database.configuration.foreignKeysEnabled, + """ + Foreign key support must be disabled to synchronize with CloudKit. + """ + ) self.container = container self.database = database self.tables = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) @@ -102,7 +106,7 @@ public final actor SyncEngine { return existingZone.schema != currentZone.schema } if !zonesToFetch.isEmpty { - // TODO: could this be removed if setUpSyncEngine was async? + // TODO: Should we avoid this unstructured task by making 'setUpSyncEngine' async? Task { await withErrorReporting(.sharingGRDBCloudKitFailure) { try await underlyingSyncEngine.fetchChanges( @@ -254,8 +258,8 @@ public final actor SyncEngine { ) } + // TODO: This is the same as 'didInsert'. Does it need special logic or can we maintain 1 method? func didUpdate(recordName: String, zoneName: String) { - // TODO: Check user modification dates underlyingSyncEngine.state.add( pendingRecordZoneChanges: [ .saveRecord( @@ -409,6 +413,7 @@ extension SyncEngine: CKSyncEngineDelegate { pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneName: table.tableName))] ) withErrorReporting(.sharingGRDBCloudKitFailure) { + // TODO: Should this work be batched? let names: [String] = try database.read { db in func open(_: T.Type) throws -> [String] { try T @@ -500,9 +505,8 @@ extension SyncEngine: CKSyncEngineDelegate { } for failedRecordSave in event.failedRecordSaves { -// switch failedRecordSave.error.code { -// -// } + // switch failedRecordSave.error.code { + // } } } From b7b226cd30fca48b644e571e304d25d73b93b2f3 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 14:02:33 -0700 Subject: [PATCH 035/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6d443085..274b29eb 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -373,7 +373,7 @@ extension SyncEngine: CKSyncEngineDelegate { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) return nil } - func open(_ table: T.Type) async -> CKRecord? { + func open(_: T.Type) async -> CKRecord? { let row = withErrorReporting { try database.read { db in @@ -493,20 +493,54 @@ extension SyncEngine: CKSyncEngineDelegate { } private func handleSentRecordZoneChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) { + for savedRecord in event.savedRecords { + refreshLastKnownServerRecord(savedRecord) + } + var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] defer { underlyingSyncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) underlyingSyncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } - - for savedRecord in event.savedRecords { - refreshLastKnownServerRecord(savedRecord) - } - for failedRecordSave in event.failedRecordSaves { - // switch failedRecordSave.error.code { - // } + let failedRecord = failedRecordSave.record + + func clearServerRecord() { + withErrorReporting { + try database.write { db in + try Metadata + .find(recordID: failedRecord.recordID) + .update { $0.lastKnownServerRecord = nil } + .execute(db) + } + } + } + + switch failedRecordSave.error.code { + case .serverRecordChanged: + guard let serverRecord = failedRecordSave.error.serverRecord else { continue } + mergeFromServerRecord(serverRecord) + refreshLastKnownServerRecord(serverRecord) + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + + case .zoneNotFound: + let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) + newPendingDatabaseChanges.append(.saveZone(zone)) + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + clearServerRecord() + + case .unknownItem: + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + clearServerRecord() + + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, + .operationCancelled: + continue + + default: + continue + } } } @@ -514,7 +548,7 @@ extension SyncEngine: CKSyncEngineDelegate { withErrorReporting(.sharingGRDBCloudKitFailure) { let userModificationDate = try metadatabase.read { db in - try Metadata.for(record.recordID).select(\.userModificationDate).fetchOne(db) + try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db) } ?? nil guard let table = tables[record.recordID.zoneID.zoneName] @@ -582,7 +616,8 @@ extension SyncEngine: CKSyncEngineDelegate { func updateLastKnownServerRecord() { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in - try Metadata.for(record.recordID) + try Metadata + .find(recordID: record.recordID) .update { $0.lastKnownServerRecord = record } .execute(db) } @@ -601,7 +636,7 @@ extension SyncEngine: CKSyncEngineDelegate { private func lastKnownServerRecord(recordID: CKRecord.ID) -> Metadata? { withErrorReporting(.sharingGRDBCloudKitFailure) { try metadatabase.read { db in - try Metadata.for(recordID).fetchOne(db) + try Metadata.find(recordID: recordID).fetchOne(db) } } ?? nil @@ -731,7 +766,7 @@ private struct Unbindable: Error {} @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata { - static func `for`(_ recordID: CKRecord.ID) -> Where { + static func find(recordID: CKRecord.ID) -> Where { Self.where { $0.zoneName.eq(recordID.zoneID.zoneName) && $0.recordName.eq(recordID.recordName) From 6344ef139da00524dd450c4931201f41f85f0e35 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 14:16:34 -0700 Subject: [PATCH 036/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 158 ++++++++++++++++-- 1 file changed, 146 insertions(+), 12 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 274b29eb..097fc065 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -177,6 +177,58 @@ public final actor SyncEngine { """ ) .execute(db) + let foreignKeys = try SQLQueryExpression( + """ + SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) + """, + as: ForeignKey.self + ) + .fetchAll(db) + for foreignKey in foreignKeys { + switch foreignKey.onDelete { + case .cascade: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + DELETE FROM \(table) + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + case .restrict: + // TODO: Report issue? + continue + + case .setDefault: + // TODO: Report issue? + continue + + case .setNull: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(table) + SET \(quote: foreignKey.from) = NULL + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + case .noAction: + continue + } + + // TODO: Create foreign key update triggers. + // switch foreignKey.onUpdate { + // } + } } try open(table) } @@ -186,19 +238,60 @@ public final actor SyncEngine { func tearDownSyncEngine() throws { try database.write { db in for table in tables.values { - try SQLQueryExpression( - """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_metadataUpdates" - """ - ) - .execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_metadataInserts" - """ - ) - .execute(db) func open(_: T.Type) throws { + let foreignKeys = try SQLQueryExpression( + """ + SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) + """, + as: ForeignKey.self + ) + .fetchAll(db) + for foreignKey in foreignKeys { + switch foreignKey.onDelete { + case .cascade: + try SQLQueryExpression( + """ + DROP TRIGGER + "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + """ + ) + .execute(db) + case .restrict: + // TODO: Report issue? + continue + + case .setDefault: + // TODO: Report issue? + continue + + case .setNull: + try SQLQueryExpression( + """ + DROP TRIGGER + "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + """ + ) + .execute(db) + case .noAction: + continue + } + + // TODO: Drop foreign key update triggers. + // switch foreignKey.onUpdate { + // } + } + try SQLQueryExpression( + """ + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_metadataUpdates" + """ + ) + .execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_metadataInserts" + """ + ) + .execute(db) try SQLQueryExpression( Trigger(on: T.self, .before, .delete, select: .willDelete).drop ) @@ -696,6 +789,47 @@ extension DatabaseFunction { } } +private struct ForeignKey: QueryDecodable, QueryRepresentable { + enum Action: String, QueryBindable { + case cascade = "CASCADE" + case restrict = "RESTRICT" + case setDefault = "SET DEFAULT" + case setNull = "SET NULL" + case noAction = "NO ACTION" + } + + typealias QueryValue = Self + + let table: String + let from: String + let to: String + let onUpdate: Action + let onDelete: Action + + init(decoder: inout some QueryDecoder) throws { + guard + let table = try decoder.decode(String.self), + let from = try decoder.decode(String.self), + let to = try decoder.decode(String.self), + let onUpdate = try decoder.decode(Action.self), + let onDelete = try decoder.decode(Action.self) + else { + throw QueryDecodingError.missingRequiredColumn + } + self.table = table + self.from = from + self.to = to + self.onUpdate = onUpdate + self.onDelete = onDelete + } + + static var columns: QueryFragment { + """ + "table", "from", "to", "on_update", "on_delete" + """ + } +} + @TaskLocal private var areTriggersEnabled = true private struct Trigger { From d66b3fe93174a48408c178a256f9690cf591eb3c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 14:25:57 -0700 Subject: [PATCH 037/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 90 ++++++++++++++++--- 1 file changed, 78 insertions(+), 12 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 097fc065..c874ae44 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -190,7 +190,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" AFTER DELETE ON \(quote: foreignKey.table) FOR EACH ROW BEGIN DELETE FROM \(table) @@ -199,6 +199,7 @@ public final actor SyncEngine { """ ) .execute(db) + case .restrict: // TODO: Report issue? continue @@ -211,7 +212,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" AFTER DELETE ON \(quote: foreignKey.table) FOR EACH ROW BEGIN UPDATE \(table) @@ -221,13 +222,53 @@ public final actor SyncEngine { """ ) .execute(db) + case .noAction: continue } - // TODO: Create foreign key update triggers. - // switch foreignKey.onUpdate { - // } + switch foreignKey.onUpdate { + case .cascade: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" + AFTER UPDATE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(T.self) + SET \(quote: foreignKey.from) = "new".\(quote: foreignKey.to) + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + + case .restrict: + // TODO: Report issue? + continue + + case .setDefault: + // TODO: Report issue? + continue + + case .setNull: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" + AFTER UPDATE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(T.self) + SET \(quote: foreignKey.from) = NULL + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + + case .noAction: + continue + } } } try open(table) @@ -252,33 +293,58 @@ public final actor SyncEngine { try SQLQueryExpression( """ DROP TRIGGER - "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" """ ) .execute(db) + case .restrict: - // TODO: Report issue? continue case .setDefault: - // TODO: Report issue? continue case .setNull: try SQLQueryExpression( """ DROP TRIGGER - "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" """ ) .execute(db) + case .noAction: continue } - // TODO: Drop foreign key update triggers. - // switch foreignKey.onUpdate { - // } + switch foreignKey.onDelete { + case .cascade: + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" + """ + ) + .execute(db) + + case .restrict: + continue + + case .setDefault: + continue + + case .setNull: + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" + """ + ) + .execute(db) + + case .noAction: + continue + } } try SQLQueryExpression( """ From 1c371d7af97ed1051c4ec6b38a6e4f10feba34da Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 14:29:18 -0700 Subject: [PATCH 038/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 27 +- Sources/SharingGRDBCore/CloudKitOld.swift | 1906 ++++++++--------- 2 files changed, 955 insertions(+), 978 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c874ae44..f5c456b4 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -128,13 +128,12 @@ public final actor SyncEngine { ) .execute(db) db.add(function: .areTriggersEnabled) - db.add(function: .didInsert) db.add(function: .didUpdate) db.add(function: .willDelete) for table in tables.values { func open(_: T.Type) throws { try SQLQueryExpression( - Trigger(on: T.self, .after, .insert, select: .didInsert).create + Trigger(on: T.self, .after, .insert, select: .didUpdate).create ) .execute(db) try SQLQueryExpression( @@ -367,7 +366,7 @@ public final actor SyncEngine { ) .execute(db) try SQLQueryExpression( - Trigger(on: T.self, .after, .insert, select: .didInsert).drop + Trigger(on: T.self, .after, .insert, select: .didUpdate).drop ) .execute(db) } @@ -375,7 +374,6 @@ public final actor SyncEngine { } db.remove(function: .willDelete) db.remove(function: .didUpdate) - db.remove(function: .didInsert) db.remove(function: .areTriggersEnabled) } try database.write { db in @@ -404,20 +402,6 @@ public final actor SyncEngine { try setUpSyncEngine() } - func didInsert(recordName: String, zoneName: String) { - underlyingSyncEngine.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: recordName, - zoneID: CKRecordZone(zoneName: zoneName).zoneID - ) - ) - ] - ) - } - - // TODO: This is the same as 'didInsert'. Does it need special logic or can we maintain 1 method? func didUpdate(recordName: String, zoneName: String) { underlyingSyncEngine.state.add( pendingRecordZoneChanges: [ @@ -811,13 +795,6 @@ extension SyncEngine: TestDependencyKey { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { - fileprivate static var didInsert: Self { - Self("didInsert") { - @Dependency(\.defaultSyncEngine) var syncEngine - await syncEngine.didInsert(recordName: $0, zoneName: $1) - } - } - fileprivate static var didUpdate: Self { Self("didUpdate") { @Dependency(\.defaultSyncEngine) var syncEngine diff --git a/Sources/SharingGRDBCore/CloudKitOld.swift b/Sources/SharingGRDBCore/CloudKitOld.swift index 3b2d13d1..7ce2d28f 100644 --- a/Sources/SharingGRDBCore/CloudKitOld.swift +++ b/Sources/SharingGRDBCore/CloudKitOld.swift @@ -1,953 +1,953 @@ -#if canImport(CloudKit) - import CloudKit - import Dependencies - import OSLog - - extension DependencyValues { - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - public var cloudKitDatabase: CloudKitDatabase { - get { self[CloudKitDatabase.self] } - set { self[CloudKitDatabase.self] = newValue } - } - } - - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - extension CloudKitDatabase: TestDependencyKey { - public static var testValue: CloudKitDatabase { - if shouldReportUnimplemented { - reportIssue("TODO") - } - return try! CloudKitDatabase( - container: CKContainer(identifier: "default"), - database: try! DatabaseQueue(), - tables: [] - ) - } - } - - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - public actor CloudKitDatabase { - let container: CKContainer - let database: any DatabaseWriter - var syncEngine: CKSyncEngine! - var stateSerialization: CKSyncEngine.State.Serialization? - let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - var delegate: Delegate - - public init( - container: CKContainer, - database: any DatabaseWriter, - tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - ) throws { - self.container = container - self.database = database - self.delegate = Delegate(container: container) - self.tables = tables - let stateSerializationData = - UserDefaults.standard.data( - forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) - ) ?? Data() - stateSerialization = try? JSONDecoder().decode( - CKSyncEngine.State.Serialization.self, - from: stateSerializationData - ) - let configuration = CKSyncEngine.Configuration( - database: container.privateCloudDatabase, - stateSerialization: stateSerialization, - delegate: delegate - ) - let syncEngine = CKSyncEngine(configuration) - self.syncEngine = syncEngine - delegate.syncEngine = syncEngine - try? FileManager.default - .createDirectory( - at: URL.applicationSupportDirectory, - withIntermediateDirectories: false - ) - let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") - logger.info("open \(url.absoluteString)") - let cloudKitDatabase = try DatabasePool(path: url.absoluteString) - var migrator = DatabaseMigrator() - migrator.registerMigration("Create SharingGRDB tables") { db in - try SQLQueryExpression( - """ - CREATE TABLE "sharing_grdb_cloudkit" ( - "tableName" TEXT NOT NULL, - "primaryKey" TEXT NOT NULL, - "recordData" BLOB, - "userModificationDate" TEXT, - PRIMARY KEY("tableName", "primaryKey") - ) - """ - ) - .execute(db) - } - try migrator.migrate(cloudKitDatabase) - try database.write { db in - try db.execute( - literal: """ - ATTACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" - """ - ) - try createTriggers(db: db, cloudKitDatabase: self) - } - Self.saveZones(syncEngine: syncEngine, tables: tables) - } - - deinit { - print("?!?!?!") - } - - func tearDownSyncEngine() throws { - let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") - try database.write { db in - try dropTriggers(db: db, tables: tables) - try db.execute( - literal: """ - DETACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" - """ - ) - } - try? FileManager.default.removeItem(at: url) - } - - func restartSyncEngine() throws { - try tearDownSyncEngine() - // setUpSyncEngine() - - // delete triggers - // delete all data from tables - // detach metadata database - // delete metadata database - // everything in initializer - - UserDefaults.standard.removeObject( - forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) - ) - stateSerialization = nil - self.delegate = Delegate(container: container) - let configuration = CKSyncEngine.Configuration( - database: container.privateCloudDatabase, - stateSerialization: stateSerialization, - delegate: delegate - ) - syncEngine = CKSyncEngine(configuration) - delegate.syncEngine = syncEngine - saveZones() - } - - static func saveZones( - syncEngine: CKSyncEngine, - tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - ) { - syncEngine.state.add( - pendingDatabaseChanges: tables.map { - .saveZone(CKRecordZone(zoneName: $0.tableName)) - } - ) - } - - func saveZones() { - Self.saveZones(syncEngine: syncEngine, tables: tables) - } - - func didInsert(tableName: String, id: String) { - syncEngine.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: id, - zoneID: CKRecordZone(zoneName: tableName).zoneID - ) - ) - ] - ) - } - - func didUpdate(tableName: String, id: String) { - // TODO: perform modification date checks - syncEngine.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: id, - zoneID: CKRecordZone(zoneName: tableName).zoneID - ) - ) - ] - ) - } - - func willDelete(tableName: String, id: String) { - syncEngine.state.add( - pendingRecordZoneChanges: [ - .deleteRecord( - CKRecord.ID( - recordName: id, - zoneID: CKRecordZone(zoneName: tableName).zoneID - ) - ) - ] - ) - } - - #if DEBUG - public func deleteAllRecords() async throws { - syncEngine.state.add( - pendingDatabaseChanges: tables.map { table in - .deleteZone(CKRecordZone.ID(zoneName: table.tableName)) - } - ) - try await syncEngine.sendChanges() - } - #endif - } - - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { - @Dependency(\.defaultDatabase) var database - let container: CKContainer - var syncEngine: CKSyncEngine! - init(container: CKContainer) { - self.container = container - } - - func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - logger.info("CloudKitDatabase.Delegate.handleEvent.\(event)") - switch event { - case .stateUpdate(let stateUpdate): - withErrorReporting { - UserDefaults.standard.set( - try JSONEncoder().encode(stateUpdate.stateSerialization), - forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) - ) - } - break - case .accountChange(_): - // TODO - break - case .fetchedDatabaseChanges(let changes): - handleFetchedDatabaseChanges(changes) - break - case .fetchedRecordZoneChanges(let changes): - handleFetchedRecordZoneChanges(changes) - break - case .sentDatabaseChanges(_): - // TODO - break - case .sentRecordZoneChanges(let changes): - handleSentRecordZoneChanges(changes) - break - case .willFetchChanges(_): - // TODO - break - case .willFetchRecordZoneChanges(_): - // TODO - break - case .didFetchRecordZoneChanges(_): - // TODO - break - case .didFetchChanges(_): - // TODO - break - case .willSendChanges(_): - // TODO - break - case .didSendChanges(_): - // TODO - break - @unknown default: - // TODO - break - } - } - - private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) { - var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() - var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() - defer { - syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) - syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) - } - - withErrorReporting { - try database.write { db in - for savedRecord in changes.savedRecords { - try db.cacheNewRecordIfNewer(savedRecord) - } - - for failedRecordSave in changes.failedRecordSaves { - // TODO: do this - switch failedRecordSave.error.code { - // case .internalError: - // <#code#> - // case .partialFailure: - // <#code#> - // case .networkUnavailable: - // <#code#> - // case .networkFailure: - // <#code#> - // case .badContainer: - // <#code#> - // case .serviceUnavailable: - // <#code#> - // case .requestRateLimited: - // <#code#> - // case .missingEntitlement: - // <#code#> - // case .notAuthenticated: - // <#code#> - // case .permissionFailure: - // <#code#> - case .unknownItem: - print("") - // case .invalidArguments: - // <#code#> - // case .resultsTruncated: - // <#code#> - case .serverRecordChanged: - guard let serverRecord = failedRecordSave.error.serverRecord - else { continue } - try db.cacheNewRecordIfNewer(serverRecord) - try serverRecord.upsertIfNewer(db: db) - print( - serverRecord.recordID, - failedRecordSave.record.recordID, - serverRecord.recordID == failedRecordSave.record.recordID - ) - newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) - // case .serverRejectedRequest: - // <#code#> - // case .assetFileNotFound: - // <#code#> - // case .assetFileModified: - // <#code#> - // case .incompatibleVersion: - // <#code#> - // case .constraintViolation: - // <#code#> - // case .operationCancelled: - // <#code#> - // case .changeTokenExpired: - // <#code#> - // case .batchRequestFailed: - // <#code#> - // case .zoneBusy: - // <#code#> - // case .badDatabase: - // <#code#> - // case .quotaExceeded: - // <#code#> - case .zoneNotFound: - // TODO: recreate zone if it matches a table name? - let zone = CKRecordZone(zoneID: failedRecordSave.record.recordID.zoneID) - newPendingDatabaseChanges.append(.saveZone(zone)) - newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) - - // case .limitExceeded: - // <#code#> - // case .userDeletedZone: - // <#code#> - // case .tooManyParticipants: - // <#code#> - // case .alreadyShared: - // <#code#> - // case .referenceViolation: - // <#code#> - // case .managedAccountRestricted: - // <#code#> - // case .participantMayNeedVerification: - // <#code#> - // case .serverResponseLost: - // <#code#> - // case .assetNotAvailable: - // <#code#> - // case .accountTemporarilyUnavailable: - // <#code#> - - case .networkFailure, - .networkUnavailable, - .zoneBusy, - .serviceUnavailable, - .notAuthenticated, - .operationCancelled: - print("") - default: - reportIssue("Unhandled error: \(failedRecordSave.error.code)") - } - } - - for (recordID, failedRecordDelete) in changes.failedRecordDeletes { - // TODO: do this - print(failedRecordDelete) - } - - // TODO: double check this is correct. the sample code doesn't have this - for deletedRecordID in changes.deletedRecordIDs { - try deletedRecordID.delete(db: db) - } - } - } - } - - private func handleFetchedRecordZoneChanges( - _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges - ) { - withErrorReporting { - try database.write { db in - for modification in changes.modifications { - try modification.record.upsertIfNewer(db: db) - try db.cacheNewRecordIfNewer(modification.record) - } - - for deletion in changes.deletions { - try deletion.recordID.delete(db: db) - } - } - } - } - - private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) - { - withErrorReporting { - try database.write { db in - for deletion in changes.deletions { - let tableName = deletion.zoneID.zoneName - try SQLQueryExpression( - """ - DELETE FROM "\(raw: tableName)" - """ - ) - .execute(db) - - syncEngine.state.add( - pendingDatabaseChanges: [ - .saveZone(CKRecordZone(zoneName: tableName)) - ] - ) - } - } - } - } - - func nextRecordZoneChangeBatch( - _ context: CKSyncEngine.SendChangesContext, - syncEngine: CKSyncEngine - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - logger.info("CloudKitDatabase.Delegate.nextRecordZoneChangeBatch \(context)") - - let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) - let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in - do { - return try database.write { db in - let record = try db.fetchLastCachedRecord(id: recordID) - let row = try Row.fetchOne( - db, - SQLRequest( - sql: """ - SELECT * FROM "\(recordID.tableName)" WHERE "id" = ? - """, - arguments: [recordID.primaryKey] - ) - ) - - guard let row - else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) - return nil - } - record.update(with: row) - try db.cacheNewRecordIfNewer(record) - return record - } - } catch { - reportIssue(error) - return nil - } - } - return batch - } - } - - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - extension CKRecord { - func update(with row: Row) { - for columnName in row.columnNames { - switch row[columnName]?.databaseValue.storage { - case .null: - if encryptedValues[columnName] != nil { - encryptedValues[columnName] = nil - } - case .int64(let value): - if object(forKey: columnName) as? Int64 != value { - encryptedValues[columnName] = value - } - case .double(let value): - if object(forKey: columnName) as? Double != value { - encryptedValues[columnName] = value - } - case .string(let value): - if object(forKey: columnName) as? String != value { - encryptedValues[columnName] = value - } - case .blob(let value): - if object(forKey: columnName) as? Data != value { - encryptedValues[columnName] = value - } - case .none: - break - } - } - } - } - - extension CKRecord.ID { - fileprivate var primaryKey: String { recordName } - fileprivate var tableName: String { zoneID.zoneName } - } - - private func stateSerializationKey(containerIdentifier: String?) -> String { - (containerIdentifier ?? "") + ".stateSerializationData" - } - - extension Database { - func cacheNewRecordIfNewer(_ newRecord: CKRecord) throws { - let existingRecord = try fetchLastCachedRecord(id: newRecord.recordID) - if let existingRecordModificationDate = existingRecord.modificationDate { - if let newRecordModificationDate = newRecord.modificationDate, - existingRecordModificationDate < newRecordModificationDate - { - try update() - } else { - print("Modification date caught") - } - } else { - try update() - } - - func update() throws { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - newRecord.encodeSystemFields(with: archiver) - // TODO: should we use userModificationDate based on record.modificationDate? - try SQLQueryExpression( - """ - INSERT INTO "sharing_grdb_cloudkit" - ("tableName", "primaryKey", "recordData", "userModificationDate") - VALUES ( - \(bind: newRecord.recordID.tableName), - \(bind: newRecord.recordID.primaryKey), - \(archiver.encodedData), - \(bind: Date.ISO8601Representation(queryOutput: .distantPast)) - ) - ON CONFLICT("tableName", "primaryKey") DO UPDATE SET - "recordData" = \(archiver.encodedData) - """ - ) - .execute(self) - } - } - - func fetchLastCachedRecord(id recordID: CKRecord.ID) throws -> CKRecord { - return try SQLQueryExpression( - """ - SELECT "recordData" - FROM "sharing_grdb_cloudkit" - WHERE "tableName" = \(bind: recordID.tableName) - AND "primaryKey" = \(bind: recordID.primaryKey) - """, - as: Data?.self - ) - .fetchOne(self) - .flatMap { $0 } - .flatMap { data in - let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) - unarchiver.requiresSecureCoding = true - return CKRecord(coder: unarchiver) - } - ?? CKRecord(recordType: recordID.tableName, recordID: recordID) - } - } - - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - extension CKRecord { - func upsertIfNewer(db: Database) throws { - let userModificationDate = - try SQLQueryExpression( - """ - SELECT "userModificationDate" FROM "sharing_grdb_cloudkit" - WHERE "tableName" = \(bind: recordID.tableName) - AND "primaryKey" = \(bind: recordID.primaryKey) - """, - as: Date?.ISO8601Representation.self - ) - .fetchOne(db) - ?? nil - - if let userModificationDate, - userModificationDate > (modificationDate ?? .distantPast) - { - print("Modification date caught") - } else { - // TODO: can we use record.keysChanged to update only columns that changed? - let columnNames = try String.fetchAll( - db, - sql: """ - SELECT "name" - FROM pragma_table_info('\(recordID.tableName)') - """ - ) - var query: QueryFragment = """ - INSERT INTO "\(raw: recordID.tableName)" ( - """ - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) - query.append( - """ - ) VALUES ( - """ - ) - query.append( - columnNames.map { columnName in - "\(bind: convert(encryptedValues[columnName]))" - }.joined(separator: ",") - ) - query.append( - """ - ) ON CONFLICT("id") DO UPDATE SET - """ - ) - query.append( - columnNames - .map { " \(quote: $0) = excluded.\(quote: $0)" } - .joined(separator: ",") - ) - try SQLQueryExpression(query).execute(db) - } - } - } - - extension CKRecord.ID { - func delete(db: Database) throws { - try SQLQueryExpression( - """ - DELETE FROM "\(raw: tableName)" - WHERE "id" = \(bind: primaryKey) - """ - ) - .execute(db) - } - } - - extension CKRecordZone.ID { - func deleteAll(db: Database) throws { - try SQLQueryExpression( - """ - DELETE FROM "\(raw: zoneName)" - """ - ) - .execute(db) - } - } - - private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression { - guard let value else { - // TODO: better way? - return SQLQueryExpression("NULL", as: Void?.self) - } - if let value = value as? Int64 { - return value - } else if let value = value as? Double { - return value - } else if let value = value as? String { - return value - } else if let value = value as? Data { - return value - } else { - fatalError("TODO: do we need to do all numeric types?") - } - } - - extension DatabaseFunction { - fileprivate convenience init( - name: String, - function: @escaping @Sendable (String, String) async -> Void - ) { - self.init(name, argumentCount: 2) { arguments in - guard - let tableName = String.fromDatabaseValue(arguments[0]), - let id = String.fromDatabaseValue(arguments[1]) - else { - return 0 - } - Task { await function(tableName, id) } - return 0 - } - } - } - - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - func dropTriggers( - db: Database, - tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - ) throws { - db.remove(function: .didInsert) - db.remove(function: .didUpdate) - db.remove(function: .willDelete) - for table in tables { - try SQLQueryExpression( - """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" - """ - ) - .execute(db) - let foreignKeys = try SQLQueryExpression( - """ - SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) - """, - as: PragmaForeignKey.self - ) - .fetchAll(db) - for foreignKey in foreignKeys { - switch foreignKey.onDelete { - case .cascade: - try SQLQueryExpression( - """ - DROP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" - """ - ) - .execute(db) - case .restrict: - fatalError("TODO: report issue?") - case .setDefault: - fatalError("TODO: report issue?") - case .setNull: - try SQLQueryExpression( - """ - DROP TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" - """ - ) - .execute(db) - case .noAction: - continue - } - - switch foreignKey.onUpdate { - case .cascade: - fatalError("TODO") - case .restrict: - fatalError("TODO") - case .setDefault: - fatalError("TODO") - case .setNull: - fatalError("TODO") - case .noAction: - continue - } - } - } - } - - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - func createTriggers( - db: Database, - cloudKitDatabase: CloudKitDatabase - ) throws { - db.add(function: .didInsert) - db.add(function: .didUpdate) - db.add(function: .willDelete) - for table in cloudKitDatabase.tables { - try Trigger.delete(tableName: table.tableName).sql - .execute(db) - try Trigger.insert(tableName: table.tableName).sql - .execute(db) - try Trigger.update(tableName: table.tableName).sql - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" - AFTER UPDATE ON \(table) FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit" - ("tableName", "primaryKey", "userModificationDate") - VALUES - ( - '\(raw: table.tableName)', - new."id", - datetime('subsec') - ) - ON CONFLICT("tableName", "primaryKey") DO UPDATE SET - "userModificationDate" = excluded."userModificationDate"; - END - """ - ) - .execute(db) - let foreignKeys = try SQLQueryExpression( - """ - SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) - """, - as: PragmaForeignKey.self - ) - .fetchAll(db) - for foreignKey in foreignKeys { - switch foreignKey.onDelete { - case .cascade: - try SQLQueryExpression( - """ - CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - DELETE FROM \(quote: table.tableName) - WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); - END - """ - ) - .execute(db) - case .restrict: - fatalError("TODO: report issue?") - case .setDefault: - fatalError("TODO: report issue?") - case .setNull: - try SQLQueryExpression( - """ - CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(quote: table.tableName) - SET \(quote: foreignKey.from) = NULL - WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); - END - """ - ) - .execute(db) - case .noAction: - continue - } - - switch foreignKey.onUpdate { - case .cascade: - fatalError("TODO") - case .restrict: - fatalError("TODO") - case .setDefault: - fatalError("TODO") - case .setNull: - fatalError("TODO") - case .noAction: - continue - } - } - } - } - - private struct PragmaForeignKey: QueryDecodable, QueryRepresentable { - enum Action: String, QueryBindable { - case cascade = "CASCADE" - case restrict = "RESTRICT" - case setDefault = "SET DEFAULT" - case setNull = "SET NULL" - case noAction = "NO ACTION" - } - - typealias QueryValue = Self - - let table: String - let from: String - let to: String - let onUpdate: Action - let onDelete: Action - - init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - guard - let table = try decoder.decode(String.self), - let from = try decoder.decode(String.self), - let to = try decoder.decode(String.self), - let onUpdate = try decoder.decode(Action.self), - let onDelete = try decoder.decode(Action.self) - else { - throw QueryDecodingError.missingRequiredColumn - } - self.table = table - self.from = from - self.to = to - self.onUpdate = onUpdate - self.onDelete = onDelete - } - - static var columns: QueryFragment { - """ - "table", "from", "to", "on_update", "on_delete", "match" - """ - } - } - - private struct Trigger { - let idColumn: String - let function: String - let tableName: String - let type: String - let when: String - static func delete(tableName: String) -> Self { - Trigger( - idColumn: "old.id", - function: "willDelete", - tableName: tableName, - type: "DELETE", - when: "BEFORE" - ) - } - static func insert(tableName: String) -> Self { - Trigger( - idColumn: "new.id", - function: "didInsert", - tableName: tableName, - type: "INSERT", - when: "AFTER" - ) - } - static func update(tableName: String) -> Self { - Trigger( - idColumn: "new.id", - function: "didUpdate", - tableName: tableName, - type: "UPDATE", - when: "AFTER" - ) - } - var sql: SQLQueryExpression { - SQLQueryExpression( - """ - CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" - \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN - SELECT \(raw: function)('\(raw: tableName)', \(raw: idColumn)); - END - """ - ) - } - } - - @available(macOS 11, iOS 14, watchOS 7, tvOS 14, *) - private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") -#endif - -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension DatabaseFunction { - fileprivate static var didInsert: Self { - @Dependency(\.cloudKitDatabase) var cloudKitDatabase - return Self( - name: "didInsert", - function: { await cloudKitDatabase.didInsert(tableName: $0, id: $1) } - ) - } - fileprivate static var didUpdate: Self { - @Dependency(\.cloudKitDatabase) var cloudKitDatabase - return Self( - name: "didUpdate", - function: { await cloudKitDatabase.didUpdate(tableName: $0, id: $1) } - ) - } - fileprivate static var willDelete: Self { - @Dependency(\.cloudKitDatabase) var cloudKitDatabase - return Self( - name: "willDelete", - function: { await cloudKitDatabase.willDelete(tableName: $0, id: $1) } - ) - } -} +//#if canImport(CloudKit) +// import CloudKit +// import Dependencies +// import OSLog +// +// extension DependencyValues { +// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +// public var cloudKitDatabase: CloudKitDatabase { +// get { self[CloudKitDatabase.self] } +// set { self[CloudKitDatabase.self] = newValue } +// } +// } +// +// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +// extension CloudKitDatabase: TestDependencyKey { +// public static var testValue: CloudKitDatabase { +// if shouldReportUnimplemented { +// reportIssue("TODO") +// } +// return try! CloudKitDatabase( +// container: CKContainer(identifier: "default"), +// database: try! DatabaseQueue(), +// tables: [] +// ) +// } +// } +// +// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +// public actor CloudKitDatabase { +// let container: CKContainer +// let database: any DatabaseWriter +// var syncEngine: CKSyncEngine! +// var stateSerialization: CKSyncEngine.State.Serialization? +// let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] +// var delegate: Delegate +// +// public init( +// container: CKContainer, +// database: any DatabaseWriter, +// tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] +// ) throws { +// self.container = container +// self.database = database +// self.delegate = Delegate(container: container) +// self.tables = tables +// let stateSerializationData = +// UserDefaults.standard.data( +// forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) +// ) ?? Data() +// stateSerialization = try? JSONDecoder().decode( +// CKSyncEngine.State.Serialization.self, +// from: stateSerializationData +// ) +// let configuration = CKSyncEngine.Configuration( +// database: container.privateCloudDatabase, +// stateSerialization: stateSerialization, +// delegate: delegate +// ) +// let syncEngine = CKSyncEngine(configuration) +// self.syncEngine = syncEngine +// delegate.syncEngine = syncEngine +// try? FileManager.default +// .createDirectory( +// at: URL.applicationSupportDirectory, +// withIntermediateDirectories: false +// ) +// let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") +// logger.info("open \(url.absoluteString)") +// let cloudKitDatabase = try DatabasePool(path: url.absoluteString) +// var migrator = DatabaseMigrator() +// migrator.registerMigration("Create SharingGRDB tables") { db in +// try SQLQueryExpression( +// """ +// CREATE TABLE "sharing_grdb_cloudkit" ( +// "tableName" TEXT NOT NULL, +// "primaryKey" TEXT NOT NULL, +// "recordData" BLOB, +// "userModificationDate" TEXT, +// PRIMARY KEY("tableName", "primaryKey") +// ) +// """ +// ) +// .execute(db) +// } +// try migrator.migrate(cloudKitDatabase) +// try database.write { db in +// try db.execute( +// literal: """ +// ATTACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" +// """ +// ) +// try createTriggers(db: db, cloudKitDatabase: self) +// } +// Self.saveZones(syncEngine: syncEngine, tables: tables) +// } +// +// deinit { +// print("?!?!?!") +// } +// +// func tearDownSyncEngine() throws { +// let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") +// try database.write { db in +// try dropTriggers(db: db, tables: tables) +// try db.execute( +// literal: """ +// DETACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" +// """ +// ) +// } +// try? FileManager.default.removeItem(at: url) +// } +// +// func restartSyncEngine() throws { +// try tearDownSyncEngine() +// // setUpSyncEngine() +// +// // delete triggers +// // delete all data from tables +// // detach metadata database +// // delete metadata database +// // everything in initializer +// +// UserDefaults.standard.removeObject( +// forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) +// ) +// stateSerialization = nil +// self.delegate = Delegate(container: container) +// let configuration = CKSyncEngine.Configuration( +// database: container.privateCloudDatabase, +// stateSerialization: stateSerialization, +// delegate: delegate +// ) +// syncEngine = CKSyncEngine(configuration) +// delegate.syncEngine = syncEngine +// saveZones() +// } +// +// static func saveZones( +// syncEngine: CKSyncEngine, +// tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] +// ) { +// syncEngine.state.add( +// pendingDatabaseChanges: tables.map { +// .saveZone(CKRecordZone(zoneName: $0.tableName)) +// } +// ) +// } +// +// func saveZones() { +// Self.saveZones(syncEngine: syncEngine, tables: tables) +// } +// +// func didInsert(tableName: String, id: String) { +// syncEngine.state.add( +// pendingRecordZoneChanges: [ +// .saveRecord( +// CKRecord.ID( +// recordName: id, +// zoneID: CKRecordZone(zoneName: tableName).zoneID +// ) +// ) +// ] +// ) +// } +// +// func didUpdate(tableName: String, id: String) { +// // TODO: perform modification date checks +// syncEngine.state.add( +// pendingRecordZoneChanges: [ +// .saveRecord( +// CKRecord.ID( +// recordName: id, +// zoneID: CKRecordZone(zoneName: tableName).zoneID +// ) +// ) +// ] +// ) +// } +// +// func willDelete(tableName: String, id: String) { +// syncEngine.state.add( +// pendingRecordZoneChanges: [ +// .deleteRecord( +// CKRecord.ID( +// recordName: id, +// zoneID: CKRecordZone(zoneName: tableName).zoneID +// ) +// ) +// ] +// ) +// } +// +// #if DEBUG +// public func deleteAllRecords() async throws { +// syncEngine.state.add( +// pendingDatabaseChanges: tables.map { table in +// .deleteZone(CKRecordZone.ID(zoneName: table.tableName)) +// } +// ) +// try await syncEngine.sendChanges() +// } +// #endif +// } +// +// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +// final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { +// @Dependency(\.defaultDatabase) var database +// let container: CKContainer +// var syncEngine: CKSyncEngine! +// init(container: CKContainer) { +// self.container = container +// } +// +// func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { +// logger.info("CloudKitDatabase.Delegate.handleEvent.\(event)") +// switch event { +// case .stateUpdate(let stateUpdate): +// withErrorReporting { +// UserDefaults.standard.set( +// try JSONEncoder().encode(stateUpdate.stateSerialization), +// forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) +// ) +// } +// break +// case .accountChange(_): +// // TODO +// break +// case .fetchedDatabaseChanges(let changes): +// handleFetchedDatabaseChanges(changes) +// break +// case .fetchedRecordZoneChanges(let changes): +// handleFetchedRecordZoneChanges(changes) +// break +// case .sentDatabaseChanges(_): +// // TODO +// break +// case .sentRecordZoneChanges(let changes): +// handleSentRecordZoneChanges(changes) +// break +// case .willFetchChanges(_): +// // TODO +// break +// case .willFetchRecordZoneChanges(_): +// // TODO +// break +// case .didFetchRecordZoneChanges(_): +// // TODO +// break +// case .didFetchChanges(_): +// // TODO +// break +// case .willSendChanges(_): +// // TODO +// break +// case .didSendChanges(_): +// // TODO +// break +// @unknown default: +// // TODO +// break +// } +// } +// +// private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) { +// var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() +// var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() +// defer { +// syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) +// syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) +// } +// +// withErrorReporting { +// try database.write { db in +// for savedRecord in changes.savedRecords { +// try db.cacheNewRecordIfNewer(savedRecord) +// } +// +// for failedRecordSave in changes.failedRecordSaves { +// // TODO: do this +// switch failedRecordSave.error.code { +// // case .internalError: +// // <#code#> +// // case .partialFailure: +// // <#code#> +// // case .networkUnavailable: +// // <#code#> +// // case .networkFailure: +// // <#code#> +// // case .badContainer: +// // <#code#> +// // case .serviceUnavailable: +// // <#code#> +// // case .requestRateLimited: +// // <#code#> +// // case .missingEntitlement: +// // <#code#> +// // case .notAuthenticated: +// // <#code#> +// // case .permissionFailure: +// // <#code#> +// case .unknownItem: +// print("") +// // case .invalidArguments: +// // <#code#> +// // case .resultsTruncated: +// // <#code#> +// case .serverRecordChanged: +// guard let serverRecord = failedRecordSave.error.serverRecord +// else { continue } +// try db.cacheNewRecordIfNewer(serverRecord) +// try serverRecord.upsertIfNewer(db: db) +// print( +// serverRecord.recordID, +// failedRecordSave.record.recordID, +// serverRecord.recordID == failedRecordSave.record.recordID +// ) +// newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) +// // case .serverRejectedRequest: +// // <#code#> +// // case .assetFileNotFound: +// // <#code#> +// // case .assetFileModified: +// // <#code#> +// // case .incompatibleVersion: +// // <#code#> +// // case .constraintViolation: +// // <#code#> +// // case .operationCancelled: +// // <#code#> +// // case .changeTokenExpired: +// // <#code#> +// // case .batchRequestFailed: +// // <#code#> +// // case .zoneBusy: +// // <#code#> +// // case .badDatabase: +// // <#code#> +// // case .quotaExceeded: +// // <#code#> +// case .zoneNotFound: +// // TODO: recreate zone if it matches a table name? +// let zone = CKRecordZone(zoneID: failedRecordSave.record.recordID.zoneID) +// newPendingDatabaseChanges.append(.saveZone(zone)) +// newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) +// +// // case .limitExceeded: +// // <#code#> +// // case .userDeletedZone: +// // <#code#> +// // case .tooManyParticipants: +// // <#code#> +// // case .alreadyShared: +// // <#code#> +// // case .referenceViolation: +// // <#code#> +// // case .managedAccountRestricted: +// // <#code#> +// // case .participantMayNeedVerification: +// // <#code#> +// // case .serverResponseLost: +// // <#code#> +// // case .assetNotAvailable: +// // <#code#> +// // case .accountTemporarilyUnavailable: +// // <#code#> +// +// case .networkFailure, +// .networkUnavailable, +// .zoneBusy, +// .serviceUnavailable, +// .notAuthenticated, +// .operationCancelled: +// print("") +// default: +// reportIssue("Unhandled error: \(failedRecordSave.error.code)") +// } +// } +// +// for (recordID, failedRecordDelete) in changes.failedRecordDeletes { +// // TODO: do this +// print(failedRecordDelete) +// } +// +// // TODO: double check this is correct. the sample code doesn't have this +// for deletedRecordID in changes.deletedRecordIDs { +// try deletedRecordID.delete(db: db) +// } +// } +// } +// } +// +// private func handleFetchedRecordZoneChanges( +// _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges +// ) { +// withErrorReporting { +// try database.write { db in +// for modification in changes.modifications { +// try modification.record.upsertIfNewer(db: db) +// try db.cacheNewRecordIfNewer(modification.record) +// } +// +// for deletion in changes.deletions { +// try deletion.recordID.delete(db: db) +// } +// } +// } +// } +// +// private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) +// { +// withErrorReporting { +// try database.write { db in +// for deletion in changes.deletions { +// let tableName = deletion.zoneID.zoneName +// try SQLQueryExpression( +// """ +// DELETE FROM "\(raw: tableName)" +// """ +// ) +// .execute(db) +// +// syncEngine.state.add( +// pendingDatabaseChanges: [ +// .saveZone(CKRecordZone(zoneName: tableName)) +// ] +// ) +// } +// } +// } +// } +// +// func nextRecordZoneChangeBatch( +// _ context: CKSyncEngine.SendChangesContext, +// syncEngine: CKSyncEngine +// ) async -> CKSyncEngine.RecordZoneChangeBatch? { +// logger.info("CloudKitDatabase.Delegate.nextRecordZoneChangeBatch \(context)") +// +// let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) +// let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in +// do { +// return try database.write { db in +// let record = try db.fetchLastCachedRecord(id: recordID) +// let row = try Row.fetchOne( +// db, +// SQLRequest( +// sql: """ +// SELECT * FROM "\(recordID.tableName)" WHERE "id" = ? +// """, +// arguments: [recordID.primaryKey] +// ) +// ) +// +// guard let row +// else { +// syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) +// return nil +// } +// record.update(with: row) +// try db.cacheNewRecordIfNewer(record) +// return record +// } +// } catch { +// reportIssue(error) +// return nil +// } +// } +// return batch +// } +// } +// +// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +// extension CKRecord { +// func update(with row: Row) { +// for columnName in row.columnNames { +// switch row[columnName]?.databaseValue.storage { +// case .null: +// if encryptedValues[columnName] != nil { +// encryptedValues[columnName] = nil +// } +// case .int64(let value): +// if object(forKey: columnName) as? Int64 != value { +// encryptedValues[columnName] = value +// } +// case .double(let value): +// if object(forKey: columnName) as? Double != value { +// encryptedValues[columnName] = value +// } +// case .string(let value): +// if object(forKey: columnName) as? String != value { +// encryptedValues[columnName] = value +// } +// case .blob(let value): +// if object(forKey: columnName) as? Data != value { +// encryptedValues[columnName] = value +// } +// case .none: +// break +// } +// } +// } +// } +// +// extension CKRecord.ID { +// fileprivate var primaryKey: String { recordName } +// fileprivate var tableName: String { zoneID.zoneName } +// } +// +// private func stateSerializationKey(containerIdentifier: String?) -> String { +// (containerIdentifier ?? "") + ".stateSerializationData" +// } +// +// extension Database { +// func cacheNewRecordIfNewer(_ newRecord: CKRecord) throws { +// let existingRecord = try fetchLastCachedRecord(id: newRecord.recordID) +// if let existingRecordModificationDate = existingRecord.modificationDate { +// if let newRecordModificationDate = newRecord.modificationDate, +// existingRecordModificationDate < newRecordModificationDate +// { +// try update() +// } else { +// print("Modification date caught") +// } +// } else { +// try update() +// } +// +// func update() throws { +// let archiver = NSKeyedArchiver(requiringSecureCoding: true) +// newRecord.encodeSystemFields(with: archiver) +// // TODO: should we use userModificationDate based on record.modificationDate? +// try SQLQueryExpression( +// """ +// INSERT INTO "sharing_grdb_cloudkit" +// ("tableName", "primaryKey", "recordData", "userModificationDate") +// VALUES ( +// \(bind: newRecord.recordID.tableName), +// \(bind: newRecord.recordID.primaryKey), +// \(archiver.encodedData), +// \(bind: Date.ISO8601Representation(queryOutput: .distantPast)) +// ) +// ON CONFLICT("tableName", "primaryKey") DO UPDATE SET +// "recordData" = \(archiver.encodedData) +// """ +// ) +// .execute(self) +// } +// } +// +// func fetchLastCachedRecord(id recordID: CKRecord.ID) throws -> CKRecord { +// return try SQLQueryExpression( +// """ +// SELECT "recordData" +// FROM "sharing_grdb_cloudkit" +// WHERE "tableName" = \(bind: recordID.tableName) +// AND "primaryKey" = \(bind: recordID.primaryKey) +// """, +// as: Data?.self +// ) +// .fetchOne(self) +// .flatMap { $0 } +// .flatMap { data in +// let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) +// unarchiver.requiresSecureCoding = true +// return CKRecord(coder: unarchiver) +// } +// ?? CKRecord(recordType: recordID.tableName, recordID: recordID) +// } +// } +// +// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +// extension CKRecord { +// func upsertIfNewer(db: Database) throws { +// let userModificationDate = +// try SQLQueryExpression( +// """ +// SELECT "userModificationDate" FROM "sharing_grdb_cloudkit" +// WHERE "tableName" = \(bind: recordID.tableName) +// AND "primaryKey" = \(bind: recordID.primaryKey) +// """, +// as: Date?.ISO8601Representation.self +// ) +// .fetchOne(db) +// ?? nil +// +// if let userModificationDate, +// userModificationDate > (modificationDate ?? .distantPast) +// { +// print("Modification date caught") +// } else { +// // TODO: can we use record.keysChanged to update only columns that changed? +// let columnNames = try String.fetchAll( +// db, +// sql: """ +// SELECT "name" +// FROM pragma_table_info('\(recordID.tableName)') +// """ +// ) +// var query: QueryFragment = """ +// INSERT INTO "\(raw: recordID.tableName)" ( +// """ +// query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) +// query.append( +// """ +// ) VALUES ( +// """ +// ) +// query.append( +// columnNames.map { columnName in +// "\(bind: convert(encryptedValues[columnName]))" +// }.joined(separator: ",") +// ) +// query.append( +// """ +// ) ON CONFLICT("id") DO UPDATE SET +// """ +// ) +// query.append( +// columnNames +// .map { " \(quote: $0) = excluded.\(quote: $0)" } +// .joined(separator: ",") +// ) +// try SQLQueryExpression(query).execute(db) +// } +// } +// } +// +// extension CKRecord.ID { +// func delete(db: Database) throws { +// try SQLQueryExpression( +// """ +// DELETE FROM "\(raw: tableName)" +// WHERE "id" = \(bind: primaryKey) +// """ +// ) +// .execute(db) +// } +// } +// +// extension CKRecordZone.ID { +// func deleteAll(db: Database) throws { +// try SQLQueryExpression( +// """ +// DELETE FROM "\(raw: zoneName)" +// """ +// ) +// .execute(db) +// } +// } +// +// private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression { +// guard let value else { +// // TODO: better way? +// return SQLQueryExpression("NULL", as: Void?.self) +// } +// if let value = value as? Int64 { +// return value +// } else if let value = value as? Double { +// return value +// } else if let value = value as? String { +// return value +// } else if let value = value as? Data { +// return value +// } else { +// fatalError("TODO: do we need to do all numeric types?") +// } +// } +// +// extension DatabaseFunction { +// fileprivate convenience init( +// name: String, +// function: @escaping @Sendable (String, String) async -> Void +// ) { +// self.init(name, argumentCount: 2) { arguments in +// guard +// let tableName = String.fromDatabaseValue(arguments[0]), +// let id = String.fromDatabaseValue(arguments[1]) +// else { +// return 0 +// } +// Task { await function(tableName, id) } +// return 0 +// } +// } +// } +// +// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +// func dropTriggers( +// db: Database, +// tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] +// ) throws { +// db.remove(function: .didInsert) +// db.remove(function: .didUpdate) +// db.remove(function: .willDelete) +// for table in tables { +// try SQLQueryExpression( +// """ +// DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" +// """ +// ) +// .execute(db) +// let foreignKeys = try SQLQueryExpression( +// """ +// SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) +// """, +// as: PragmaForeignKey.self +// ) +// .fetchAll(db) +// for foreignKey in foreignKeys { +// switch foreignKey.onDelete { +// case .cascade: +// try SQLQueryExpression( +// """ +// DROP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" +// """ +// ) +// .execute(db) +// case .restrict: +// fatalError("TODO: report issue?") +// case .setDefault: +// fatalError("TODO: report issue?") +// case .setNull: +// try SQLQueryExpression( +// """ +// DROP TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" +// """ +// ) +// .execute(db) +// case .noAction: +// continue +// } +// +// switch foreignKey.onUpdate { +// case .cascade: +// fatalError("TODO") +// case .restrict: +// fatalError("TODO") +// case .setDefault: +// fatalError("TODO") +// case .setNull: +// fatalError("TODO") +// case .noAction: +// continue +// } +// } +// } +// } +// +// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +// func createTriggers( +// db: Database, +// cloudKitDatabase: CloudKitDatabase +// ) throws { +// db.add(function: .didInsert) +// db.add(function: .didUpdate) +// db.add(function: .willDelete) +// for table in cloudKitDatabase.tables { +// try Trigger.delete(tableName: table.tableName).sql +// .execute(db) +// try Trigger.insert(tableName: table.tableName).sql +// .execute(db) +// try Trigger.update(tableName: table.tableName).sql +// .execute(db) +// try SQLQueryExpression( +// """ +// CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" +// AFTER UPDATE ON \(table) FOR EACH ROW BEGIN +// INSERT INTO "sharing_grdb_cloudkit" +// ("tableName", "primaryKey", "userModificationDate") +// VALUES +// ( +// '\(raw: table.tableName)', +// new."id", +// datetime('subsec') +// ) +// ON CONFLICT("tableName", "primaryKey") DO UPDATE SET +// "userModificationDate" = excluded."userModificationDate"; +// END +// """ +// ) +// .execute(db) +// let foreignKeys = try SQLQueryExpression( +// """ +// SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) +// """, +// as: PragmaForeignKey.self +// ) +// .fetchAll(db) +// for foreignKey in foreignKeys { +// switch foreignKey.onDelete { +// case .cascade: +// try SQLQueryExpression( +// """ +// CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" +// AFTER DELETE ON \(quote: foreignKey.table) +// FOR EACH ROW BEGIN +// DELETE FROM \(quote: table.tableName) +// WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); +// END +// """ +// ) +// .execute(db) +// case .restrict: +// fatalError("TODO: report issue?") +// case .setDefault: +// fatalError("TODO: report issue?") +// case .setNull: +// try SQLQueryExpression( +// """ +// CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" +// AFTER DELETE ON \(quote: foreignKey.table) +// FOR EACH ROW BEGIN +// UPDATE \(quote: table.tableName) +// SET \(quote: foreignKey.from) = NULL +// WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); +// END +// """ +// ) +// .execute(db) +// case .noAction: +// continue +// } +// +// switch foreignKey.onUpdate { +// case .cascade: +// fatalError("TODO") +// case .restrict: +// fatalError("TODO") +// case .setDefault: +// fatalError("TODO") +// case .setNull: +// fatalError("TODO") +// case .noAction: +// continue +// } +// } +// } +// } +// +// private struct PragmaForeignKey: QueryDecodable, QueryRepresentable { +// enum Action: String, QueryBindable { +// case cascade = "CASCADE" +// case restrict = "RESTRICT" +// case setDefault = "SET DEFAULT" +// case setNull = "SET NULL" +// case noAction = "NO ACTION" +// } +// +// typealias QueryValue = Self +// +// let table: String +// let from: String +// let to: String +// let onUpdate: Action +// let onDelete: Action +// +// init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { +// guard +// let table = try decoder.decode(String.self), +// let from = try decoder.decode(String.self), +// let to = try decoder.decode(String.self), +// let onUpdate = try decoder.decode(Action.self), +// let onDelete = try decoder.decode(Action.self) +// else { +// throw QueryDecodingError.missingRequiredColumn +// } +// self.table = table +// self.from = from +// self.to = to +// self.onUpdate = onUpdate +// self.onDelete = onDelete +// } +// +// static var columns: QueryFragment { +// """ +// "table", "from", "to", "on_update", "on_delete", "match" +// """ +// } +// } +// +// private struct Trigger { +// let idColumn: String +// let function: String +// let tableName: String +// let type: String +// let when: String +// static func delete(tableName: String) -> Self { +// Trigger( +// idColumn: "old.id", +// function: "willDelete", +// tableName: tableName, +// type: "DELETE", +// when: "BEFORE" +// ) +// } +// static func insert(tableName: String) -> Self { +// Trigger( +// idColumn: "new.id", +// function: "didInsert", +// tableName: tableName, +// type: "INSERT", +// when: "AFTER" +// ) +// } +// static func update(tableName: String) -> Self { +// Trigger( +// idColumn: "new.id", +// function: "didUpdate", +// tableName: tableName, +// type: "UPDATE", +// when: "AFTER" +// ) +// } +// var sql: SQLQueryExpression { +// SQLQueryExpression( +// """ +// CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" +// \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN +// SELECT \(raw: function)('\(raw: tableName)', \(raw: idColumn)); +// END +// """ +// ) +// } +// } +// +// @available(macOS 11, iOS 14, watchOS 7, tvOS 14, *) +// private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") +//#endif +// +//@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +//extension DatabaseFunction { +// fileprivate static var didInsert: Self { +// @Dependency(\.cloudKitDatabase) var cloudKitDatabase +// return Self( +// name: "didInsert", +// function: { await cloudKitDatabase.didInsert(tableName: $0, id: $1) } +// ) +// } +// fileprivate static var didUpdate: Self { +// @Dependency(\.cloudKitDatabase) var cloudKitDatabase +// return Self( +// name: "didUpdate", +// function: { await cloudKitDatabase.didUpdate(tableName: $0, id: $1) } +// ) +// } +// fileprivate static var willDelete: Self { +// @Dependency(\.cloudKitDatabase) var cloudKitDatabase +// return Self( +// name: "willDelete", +// function: { await cloudKitDatabase.willDelete(tableName: $0, id: $1) } +// ) +// } +//} From 93696b65b986425a235ad236d2473ad8846c49cf Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 14:36:02 -0700 Subject: [PATCH 039/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index f5c456b4..7efca55e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -509,7 +509,7 @@ extension SyncEngine: CKSyncEngineDelegate { let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in - let record = await lastKnownServerRecord(recordID: recordID) + let metadata = await metadataFor(recordID: recordID) guard let table = tables[recordID.zoneID.zoneName] else { reportIssue("") @@ -530,18 +530,18 @@ extension SyncEngine: CKSyncEngineDelegate { return nil } - let ckRecord = - record?.lastKnownServerRecord + let record = + metadata?.lastKnownServerRecord ?? CKRecord( recordType: recordID.zoneID.zoneName, recordID: recordID ) - ckRecord.update( + record.update( with: T(queryOutput: row), - userModificationDate: record?.userModificationDate + userModificationDate: metadata?.userModificationDate ) - await refreshLastKnownServerRecord(ckRecord) - return ckRecord + await refreshLastKnownServerRecord(record) + return record } return await open(table) } @@ -556,7 +556,6 @@ extension SyncEngine: CKSyncEngineDelegate { pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneName: table.tableName))] ) withErrorReporting(.sharingGRDBCloudKitFailure) { - // TODO: Should this work be batched? let names: [String] = try database.read { db in func open(_: T.Type) throws -> [String] { try T @@ -754,7 +753,7 @@ extension SyncEngine: CKSyncEngineDelegate { } private func refreshLastKnownServerRecord(_ record: CKRecord) { - let localRecord = lastKnownServerRecord(recordID: record.recordID) + let localRecord = metadataFor(recordID: record.recordID) func updateLastKnownServerRecord() { withErrorReporting(.sharingGRDBCloudKitFailure) { @@ -776,7 +775,7 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func lastKnownServerRecord(recordID: CKRecord.ID) -> Metadata? { + private func metadataFor(recordID: CKRecord.ID) -> Metadata? { withErrorReporting(.sharingGRDBCloudKitFailure) { try metadatabase.read { db in try Metadata.find(recordID: recordID).fetchOne(db) From 8252ea5c428e30fc845eda9eb89d3b4fc2d3d2b3 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 16 May 2025 18:40:36 -0500 Subject: [PATCH 040/581] wip; --- Examples/Reminders/RemindersDetail.swift | 2 +- Examples/Reminders/RemindersLists.swift | 8 ++ Examples/Reminders/Schema.swift | 80 ++++++++++--------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 10 ++- 4 files changed, 60 insertions(+), 40 deletions(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 604517d8..1439d565 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -194,7 +194,7 @@ struct RemindersDetailView: View { .order { $0.isCompleted } .order { switch ordering { - case .dueDate: $0.dueDate + case .dueDate: $0.dueDate.asc(nulls: .last) case .manual: $0.position case .priority: ($0.priority.desc(), $0.isFlagged.desc()) case .title: $0.title diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 8c43f5fc..fe81849e 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -183,6 +183,14 @@ struct RemindersListsView: View { Text("Clear data") Image(systemName: "xmark") } + Button { + Task { + try await syncEngine.fetchChanges() + } + } label: { + Text("Fetch changes") + Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90") + } Button { withErrorReporting { try database.write { db in diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index df5e2961..9e974415 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -250,117 +250,121 @@ let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { func seedSampleData() throws { + // TODO: If we figure out the reverting problem I think this can go back to using UUID(1), ... + let remindersListIDs = (1...3).map { _ in UUID() } + let reminderIDs = (1...10).map { _ in UUID() } + let tagIDs = (1...7).map { _ in UUID() } try seed { RemindersList( - id: UUID(1), + id: remindersListIDs[0], color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), title: "Personal" ) RemindersList( - id: UUID(2), + id: remindersListIDs[1], color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), title: "Family" ) RemindersList( - id: UUID(3), + id: remindersListIDs[2], color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), title: "Business" ) Reminder( - id: UUID(1), + id: reminderIDs[0], notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - remindersListID: UUID(1), + remindersListID: remindersListIDs[0], title: "Groceries" ) Reminder( - id: UUID(2), + id: reminderIDs[1], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isFlagged: true, - remindersListID: UUID(1), + remindersListID: remindersListIDs[0], title: "Haircut" ) Reminder( - id: UUID(3), + id: reminderIDs[2], dueDate: Date(), notes: "Ask about diet", priority: .high, - remindersListID: UUID(1), + remindersListID: remindersListIDs[0], title: "Doctor appointment" ) Reminder( - id: UUID(4), + id: reminderIDs[3], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), isCompleted: true, - remindersListID: UUID(1), + remindersListID: remindersListIDs[0], title: "Take a walk" ) Reminder( - id: UUID(5), + id: reminderIDs[4], dueDate: Date(), - remindersListID: UUID(1), + remindersListID: remindersListIDs[0], title: "Buy concert tickets" ) Reminder( - id: UUID(6), + id: reminderIDs[5], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), isFlagged: true, priority: .high, - remindersListID: UUID(2), + remindersListID: remindersListIDs[1], title: "Pick up kids from school" ) Reminder( - id: UUID(7), + id: reminderIDs[6], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .low, - remindersListID: UUID(2), + remindersListID: remindersListIDs[1], title: "Get laundry" ) Reminder( - id: UUID(8), + id: reminderIDs[7], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), isCompleted: false, priority: .high, - remindersListID: UUID(2), + remindersListID: remindersListIDs[1], title: "Take out trash" ) Reminder( - id: UUID(9), + id: reminderIDs[8], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), notes: """ Status of tax return Expenses for next year Changing payroll company """, - remindersListID: UUID(3), + remindersListID: remindersListIDs[2], title: "Call accountant" ) Reminder( - id: UUID(10), + id: reminderIDs[9], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .medium, - remindersListID: UUID(3), + remindersListID: remindersListIDs[2], title: "Send weekly emails" ) - Tag(id: UUID(1), title: "car") - Tag(id: UUID(2), title: "kids") - Tag(id: UUID(3), title: "someday") - Tag(id: UUID(4), title: "optional") - Tag(id: UUID(5), title: "social") - Tag(id: UUID(6), title: "night") - Tag(id: UUID(7), title: "adulting") + Tag(id: tagIDs[0], title: "car") + Tag(id: tagIDs[1], title: "kids") + Tag(id: tagIDs[2], title: "someday") + Tag(id: tagIDs[3], title: "optional") + Tag(id: tagIDs[4], title: "social") + Tag(id: tagIDs[5], title: "night") + Tag(id: tagIDs[6], title: "adulting") - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(3)) - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(4)) - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(7)) - ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(3)) - ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(4)) - ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(7)) - ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(1)) - ReminderTag(id: UUID(), reminderID: UUID(4), tagID: UUID(2)) + ReminderTag(id: UUID(), reminderID: reminderIDs[0], tagID: tagIDs[2]) + ReminderTag(id: UUID(), reminderID: reminderIDs[0], tagID: tagIDs[3]) + ReminderTag(id: UUID(), reminderID: reminderIDs[0], tagID: tagIDs[6]) + ReminderTag(id: UUID(), reminderID: reminderIDs[1], tagID: tagIDs[2]) + ReminderTag(id: UUID(), reminderID: reminderIDs[1], tagID: tagIDs[3]) + ReminderTag(id: UUID(), reminderID: reminderIDs[2], tagID: tagIDs[6]) + ReminderTag(id: UUID(), reminderID: reminderIDs[3], tagID: tagIDs[0]) + ReminderTag(id: UUID(), reminderID: reminderIDs[3], tagID: tagIDs[1]) } } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 7efca55e..04ffaab7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -385,6 +385,10 @@ public final actor SyncEngine { try FileManager.default.removeItem(at: metadatabaseURL) } + public func fetchChanges() async throws { + try await underlyingSyncEngine.fetchChanges() + } + public func deleteLocalData() throws { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in @@ -677,7 +681,7 @@ extension SyncEngine: CKSyncEngineDelegate { clearServerRecord() case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, - .operationCancelled: + .operationCancelled, .batchRequestFailed: continue default: @@ -744,6 +748,10 @@ extension SyncEngine: CKSyncEngineDelegate { ) try database.write { db in try $areTriggersEnabled.withValue(false) { + try Metadata + .find(recordID: record.recordID) + .update { $0.userModificationDate = record.userModificationDate } + .execute(db) try SQLQueryExpression(query).execute(db) } } From 2074345a278dcb8d39a6b897f8df610553aafa62 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 19:35:03 -0700 Subject: [PATCH 041/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 04ffaab7..ff45b853 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -748,11 +748,13 @@ extension SyncEngine: CKSyncEngineDelegate { ) try database.write { db in try $areTriggersEnabled.withValue(false) { + try SQLQueryExpression(query).execute(db) try Metadata - .find(recordID: record.recordID) - .update { $0.userModificationDate = record.userModificationDate } + .insert(Metadata(record: record)) { + $0.lastKnownServerRecord = record + $0.userModificationDate = record.userModificationDate + } .execute(db) - try SQLQueryExpression(query).execute(db) } } return @@ -956,6 +958,15 @@ extension Metadata { && $0.recordName.eq(recordID.recordName) } } + + init(record: CKRecord) { + self.init( + zoneName: record.recordID.zoneID.zoneName, + recordName: record.recordID.recordName, + lastKnownServerRecord: record, + userModificationDate: record.userModificationDate + ) + } } extension String { From c6557bda1777fb5e3ddeb86b9cba916bc44a3880 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 16 May 2025 22:19:54 -0500 Subject: [PATCH 042/581] Support ON DELETE SET DEFAULT foreign keys --- Examples/Reminders/RemindersApp.swift | 10 ------ Examples/Reminders/Schema.swift | 2 +- Examples/Reminders/SearchReminders.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 30 ++++++++++++++++-- db1.sqlite | Bin 8192 -> 12288 bytes 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index c8e24fac..a0bfa9f8 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -10,16 +10,6 @@ struct RemindersApp: App { if context == .live { try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() -// $0.cloudKitDatabase = try CloudKitDatabase( -// container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), -// database: $0.defaultDatabase, -// tables: [ -// Reminder.self, -// RemindersList.self, -// Tag.self, -// ReminderTag.self, -// ] -// ) $0.defaultSyncEngine = SyncEngine( container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), database: $0.defaultDatabase, diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 9e974415..9d64ca91 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -150,7 +150,7 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "tags" ( "id" TEXT PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL COLLATE NOCASE UNIQUE + "title" TEXT NOT NULL COLLATE NOCASE ) STRICT """ ) diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 35de7334..24ce8edc 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -72,7 +72,7 @@ struct SearchRemindersView: View { Reminder .searching(searchText) .where { showCompletedInSearchResults || !$0.isCompleted } - .order { ($0.isCompleted, $0.dueDate) } + .order { ($0.isCompleted, $0.dueDate.asc(nulls: .last)) } .withTags .join(RemindersList.all) { $0.remindersListID.eq($3.id) } .select { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ff45b853..19b6d4e5 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -204,8 +204,34 @@ public final actor SyncEngine { continue case .setDefault: - // TODO: Report issue? - continue + let defaultValue = try SQLQueryExpression( + """ + SELECT "dflt_value" + FROM pragma_table_info(\(bind: table.tableName)) + WHERE "name" = \(bind: foreignKey.from) + """, + as: String?.self + ) + .fetchOne(db) ?? nil + + guard let defaultValue + else { + // TODO: report issue + continue + } + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(table) + SET \(quote: foreignKey.from) = '\(raw: defaultValue)' + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) case .setNull: try SQLQueryExpression( diff --git a/db1.sqlite b/db1.sqlite index 62db540e09b02cf822221cab6c752461efce379c..a7c656171f573e7eedacad726461ee4730b60b3d 100644 GIT binary patch delta 136 zcmZp0Xh@hKEy&EkzyQK9z%)_E7$~Thw~H4j#Kd39z`u>ZbYmeazoR`9o4BMTV{}Pk zQcfzEaDczyQK9z&KIIn4gJ3FK-twP>7L#8w3Bg&4L0R{F}G&dkFvl*YF8W From be64344ce407b60c06de0f95d53bc9f6ed32bf61 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 22:30:13 -0700 Subject: [PATCH 043/581] wip --- ...swift => CloudKit+StructuredQueries.swift} | 0 .../SharingGRDBCore/CloudKit/SyncEngine.swift | 41 ++++++++++-------- db1.sqlite | Bin 12288 -> 0 bytes 3 files changed, 22 insertions(+), 19 deletions(-) rename Sources/SharingGRDBCore/CloudKit/{CKRecord.swift => CloudKit+StructuredQueries.swift} (100%) delete mode 100644 db1.sqlite diff --git a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/CKRecord.swift rename to Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 19b6d4e5..efdfd37a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -13,7 +13,7 @@ extension DependencyValues { public final actor SyncEngine { nonisolated let container: CKContainer nonisolated let database: any DatabaseWriter - nonisolated let tables: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + nonisolated let tables: [String: any PrimaryKeyedTable.Type] lazy var metadatabase: any DatabaseWriter = try! DatabaseQueue() var stateSerialization: CKSyncEngine.State.Serialization? lazy var underlyingSyncEngine: CKSyncEngine = defaultSyncEngine @@ -21,7 +21,7 @@ public final actor SyncEngine { public init( container: CKContainer, database: any DatabaseWriter, - tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + tables: [any PrimaryKeyedTable.Type] ) { // TODO: Explain why / link to documentation? precondition( @@ -152,7 +152,7 @@ public final actor SyncEngine { INSERT INTO \(Metadata.self) ("zoneName", "recordName", "userModificationDate") SELECT - '\(raw: table.tableName)', + \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), datetime('subsec') WHERE areTriggersEnabled() @@ -168,7 +168,9 @@ public final actor SyncEngine { AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) ("zoneName", "recordName") - SELECT '\(raw: table.tableName)', "new".\(quote: T.columns.primaryKey.name) + SELECT + \(quote: T.tableName, delimiter: .text), + "new".\(quote: T.columns.primaryKey.name) WHERE areTriggersEnabled() ON CONFLICT("zoneName", "recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); @@ -178,7 +180,7 @@ public final actor SyncEngine { .execute(db) let foreignKeys = try SQLQueryExpression( """ - SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) + SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: T.tableName)) """, as: ForeignKey.self ) @@ -204,19 +206,20 @@ public final actor SyncEngine { continue case .setDefault: - let defaultValue = try SQLQueryExpression( - """ - SELECT "dflt_value" - FROM pragma_table_info(\(bind: table.tableName)) - WHERE "name" = \(bind: foreignKey.from) - """, - as: String?.self - ) - .fetchOne(db) ?? nil + let defaultValue = + try SQLQueryExpression( + """ + SELECT "dflt_value" + FROM pragma_table_info(\(bind: T.tableName)) + WHERE "name" = \(bind: foreignKey.from) + """, + as: String?.self + ) + .fetchOne(db) ?? nil guard let defaultValue else { - // TODO: report issue + // TODO: Report issue? continue } try SQLQueryExpression( @@ -226,7 +229,7 @@ public final actor SyncEngine { AFTER DELETE ON \(quote: foreignKey.table) FOR EACH ROW BEGIN UPDATE \(table) - SET \(quote: foreignKey.from) = '\(raw: defaultValue)' + SET \(quote: foreignKey.from) = \(raw: defaultValue) WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); END """ @@ -307,7 +310,7 @@ public final actor SyncEngine { func open(_: T.Type) throws { let foreignKeys = try SQLQueryExpression( """ - SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) + SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: T.tableName)) """, as: ForeignKey.self ) @@ -373,13 +376,13 @@ public final actor SyncEngine { } try SQLQueryExpression( """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_metadataUpdates" + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" """ ) .execute(db) try SQLQueryExpression( """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_metadataInserts" + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" """ ) .execute(db) diff --git a/db1.sqlite b/db1.sqlite deleted file mode 100644 index a7c656171f573e7eedacad726461ee4730b60b3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI#y-EW?5Ww;M5J6DZPSOomC5;eGDd;KJrP0L7>f|^TD=HMR)qYTC2_% z?-F?gD4*xPNqL*?-9wR23z_D( z<3ie*^y^CMMUcwR%Jn?R=wfiCn+1 Date: Fri, 16 May 2025 22:32:44 -0700 Subject: [PATCH 044/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index efdfd37a..29bcf0ed 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -276,8 +276,35 @@ public final actor SyncEngine { continue case .setDefault: - // TODO: Report issue? - continue + let defaultValue = + try SQLQueryExpression( + """ + SELECT "dflt_value" + FROM pragma_table_info(\(bind: T.tableName)) + WHERE "name" = \(bind: foreignKey.from) + """, + as: String?.self + ) + .fetchOne(db) ?? nil + + guard let defaultValue + else { + // TODO: Report issue? + continue + } + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" + AFTER UPDATE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(table) + SET \(quote: foreignKey.from) = \(raw: defaultValue) + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) case .setNull: try SQLQueryExpression( @@ -330,7 +357,13 @@ public final actor SyncEngine { continue case .setDefault: - continue + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" + """ + ) + .execute(db) case .setNull: try SQLQueryExpression( @@ -359,7 +392,13 @@ public final actor SyncEngine { continue case .setDefault: - continue + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" + """ + ) + .execute(db) case .setNull: try SQLQueryExpression( From 53cc7ee5f3a797ec3a3dce00d1f98bc160eb2f4b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 16 May 2025 22:49:38 -0700 Subject: [PATCH 045/581] wip --- Examples/Reminders/Schema.swift | 80 +++++++++---------- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- 2 files changed, 39 insertions(+), 43 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 9d64ca91..0b0ed0c4 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -250,121 +250,117 @@ let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { func seedSampleData() throws { - // TODO: If we figure out the reverting problem I think this can go back to using UUID(1), ... - let remindersListIDs = (1...3).map { _ in UUID() } - let reminderIDs = (1...10).map { _ in UUID() } - let tagIDs = (1...7).map { _ in UUID() } try seed { RemindersList( - id: remindersListIDs[0], + id: UUID(0), color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), title: "Personal" ) RemindersList( - id: remindersListIDs[1], + id: UUID(1), color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), title: "Family" ) RemindersList( - id: remindersListIDs[2], + id: UUID(2), color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), title: "Business" ) Reminder( - id: reminderIDs[0], + id: UUID(0), notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - remindersListID: remindersListIDs[0], + remindersListID: UUID(0), title: "Groceries" ) Reminder( - id: reminderIDs[1], + id: UUID(1), dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isFlagged: true, - remindersListID: remindersListIDs[0], + remindersListID: UUID(0), title: "Haircut" ) Reminder( - id: reminderIDs[2], + id: UUID(2), dueDate: Date(), notes: "Ask about diet", priority: .high, - remindersListID: remindersListIDs[0], + remindersListID: UUID(0), title: "Doctor appointment" ) Reminder( - id: reminderIDs[3], + id: UUID(3), dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), isCompleted: true, - remindersListID: remindersListIDs[0], + remindersListID: UUID(0), title: "Take a walk" ) Reminder( - id: reminderIDs[4], + id: UUID(4), dueDate: Date(), - remindersListID: remindersListIDs[0], + remindersListID: UUID(0), title: "Buy concert tickets" ) Reminder( - id: reminderIDs[5], + id: UUID(5), dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), isFlagged: true, priority: .high, - remindersListID: remindersListIDs[1], + remindersListID: UUID(1), title: "Pick up kids from school" ) Reminder( - id: reminderIDs[6], + id: UUID(6), dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .low, - remindersListID: remindersListIDs[1], + remindersListID: UUID(1), title: "Get laundry" ) Reminder( - id: reminderIDs[7], + id: UUID(7), dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), isCompleted: false, priority: .high, - remindersListID: remindersListIDs[1], + remindersListID: UUID(1), title: "Take out trash" ) Reminder( - id: reminderIDs[8], + id: UUID(8), dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), notes: """ Status of tax return Expenses for next year Changing payroll company """, - remindersListID: remindersListIDs[2], + remindersListID: UUID(2), title: "Call accountant" ) Reminder( - id: reminderIDs[9], + id: UUID(9), dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .medium, - remindersListID: remindersListIDs[2], + remindersListID: UUID(2), title: "Send weekly emails" ) - Tag(id: tagIDs[0], title: "car") - Tag(id: tagIDs[1], title: "kids") - Tag(id: tagIDs[2], title: "someday") - Tag(id: tagIDs[3], title: "optional") - Tag(id: tagIDs[4], title: "social") - Tag(id: tagIDs[5], title: "night") - Tag(id: tagIDs[6], title: "adulting") + Tag(id: UUID(0), title: "car") + Tag(id: UUID(1), title: "kids") + Tag(id: UUID(2), title: "someday") + Tag(id: UUID(3), title: "optional") + Tag(id: UUID(4), title: "social") + Tag(id: UUID(5), title: "night") + Tag(id: UUID(6), title: "adulting") - ReminderTag(id: UUID(), reminderID: reminderIDs[0], tagID: tagIDs[2]) - ReminderTag(id: UUID(), reminderID: reminderIDs[0], tagID: tagIDs[3]) - ReminderTag(id: UUID(), reminderID: reminderIDs[0], tagID: tagIDs[6]) - ReminderTag(id: UUID(), reminderID: reminderIDs[1], tagID: tagIDs[2]) - ReminderTag(id: UUID(), reminderID: reminderIDs[1], tagID: tagIDs[3]) - ReminderTag(id: UUID(), reminderID: reminderIDs[2], tagID: tagIDs[6]) - ReminderTag(id: UUID(), reminderID: reminderIDs[3], tagID: tagIDs[0]) - ReminderTag(id: UUID(), reminderID: reminderIDs[3], tagID: tagIDs[1]) + ReminderTag(id: UUID(), reminderID: UUID(0), tagID: UUID(2)) + ReminderTag(id: UUID(), reminderID: UUID(0), tagID: UUID(3)) + ReminderTag(id: UUID(), reminderID: UUID(0), tagID: UUID(6)) + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(2)) + ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(3)) + ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(6)) + ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(0)) + ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(1)) } } } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 0e9d3237..ceb4696d 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -35,7 +35,7 @@ extension CKRecord? { typealias DataRepresentation = CKRecord.DataRepresentation? } -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { func update(with row: T, userModificationDate: Date?) { self.userModificationDate = userModificationDate From e4f23e803c04a932ec6cf9585f01a1257bd5b12d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 17 May 2025 19:37:40 -0600 Subject: [PATCH 046/581] some improved logging --- Examples/Reminders/Schema.swift | 5 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 338 +++++++++++++++++- db1.sqlite | Bin 12288 -> 12288 bytes 3 files changed, 325 insertions(+), 18 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 9d64ca91..7f4bb77d 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -97,15 +97,16 @@ func appDatabase() throws -> any DatabaseWriter { if context == .preview { print("\($0.expandedDescription)") } else { - logger.debug("\($0.expandedDescription)") + logger.trace("\($0.expandedDescription)") } } #endif } if context == .live { let path = URL.documentsDirectory.appending(component: "db.sqlite").path(percentEncoded: false) - logger.info( + logger.debug( """ + App database open "\(path)" """ ) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 19b6d4e5..70cbcdab 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1,4 +1,5 @@ import CloudKit +import ConcurrencyExtras import OSLog @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -204,15 +205,16 @@ public final actor SyncEngine { continue case .setDefault: - let defaultValue = try SQLQueryExpression( - """ - SELECT "dflt_value" - FROM pragma_table_info(\(bind: table.tableName)) - WHERE "name" = \(bind: foreignKey.from) - """, - as: String?.self - ) - .fetchOne(db) ?? nil + let defaultValue = + try SQLQueryExpression( + """ + SELECT "dflt_value" + FROM pragma_table_info(\(bind: table.tableName)) + WHERE "name" = \(bind: foreignKey.from) + """, + as: String?.self + ) + .fetchOne(db) ?? nil guard let defaultValue else { @@ -412,7 +414,7 @@ public final actor SyncEngine { } public func fetchChanges() async throws { - try await underlyingSyncEngine.fetchChanges() + try await underlyingSyncEngine.fetchChanges(.init(scope: .all, operationGroup: nil)) } public func deleteLocalData() throws { @@ -467,11 +469,12 @@ public final actor SyncEngine { var configuration = Configuration() configuration.prepareDatabase { db in db.trace { - logger.debug("\($0.expandedDescription)") + logger.trace("\($0.expandedDescription)") } } - logger.info( + logger.debug( """ + SharingGRDB: Metadatabase connection: open "\(self.metadatabaseURL.path(percentEncoded: false))" """ ) @@ -500,7 +503,7 @@ public final actor SyncEngine { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - logger.debug("handleEvent: \(event)") + logger.log(event) switch event { case .accountChange(let event): @@ -527,7 +530,7 @@ extension SyncEngine: CKSyncEngineDelegate { .didFetchChanges, .willSendChanges, .didSendChanges: break @unknown default: - logger.warning("Sync engine received unknown event: \(event)") + break } } @@ -535,15 +538,68 @@ extension SyncEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - logger.debug("nextRecordZoneChangeBatch: \(context)") - let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) + guard !changes.isEmpty + else { return nil } + + #if DEBUG + struct State { + var missingTables: [CKRecord.ID] = [] + var missingRecords: [CKRecord.ID] = [] + var sentRecords: [CKRecord.ID] = [] + } + let state = LockIsolated(State()) + defer { + let state = state.withValue(\.self) + let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + logger.debug( + """ + SharingGRDB: nextRecordZoneChangeBatch: \(context.reason) + \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") + \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") + \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") + """ + ) + } + #endif + let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in + #if DEBUG + var missingTable: CKRecord.ID? + var missingRecord: CKRecord.ID? + var sentRecord: CKRecord.ID? + defer { + state.withValue { [missingTable, missingRecord, sentRecord] in + if let missingTable { $0.missingTables.append(missingTable) } + if let missingRecord { $0.missingRecords.append(missingRecord) } + if let sentRecord { $0.sentRecords.append(sentRecord) } + } + } + #endif + let metadata = await metadataFor(recordID: recordID) guard let table = tables[recordID.zoneID.zoneName] else { reportIssue("") syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingTable = recordID return nil } func open(_: T.Type) async -> CKRecord? { @@ -557,6 +613,7 @@ extension SyncEngine: CKSyncEngineDelegate { guard let row else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingRecord = recordID return nil } @@ -571,6 +628,7 @@ extension SyncEngine: CKSyncEngineDelegate { userModificationDate: metadata?.userModificationDate ) await refreshLastKnownServerRecord(record) + sentRecord = recordID return record } return await open(table) @@ -628,6 +686,9 @@ extension SyncEngine: CKSyncEngineDelegate { open(table) } } + + // TODO: Deal with modifications? + _ = event.modifications } } } @@ -1011,3 +1072,248 @@ extension URL { @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Logger { + func log(_ event: CKSyncEngine.Event) { + let prefix = "SharingGRDB: handleEvent:" + switch event { + case .stateUpdate: + debug("\(prefix) stateUpdate") + case .accountChange(let event): + switch event.changeType { + case .signIn(let currentUser): + debug( + """ + \(prefix) signIn + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + case .signOut(let previousUser): + debug( + """ + \(prefix) signOut + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + """ + ) + case .switchAccounts(let previousUser, let currentUser): + debug( + """ + \(prefix) switchAccounts: + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + @unknown default: + debug("unknown") + } + case .fetchedDatabaseChanges(let event): + let deletions = + event.deletions.isEmpty + ? "⚪️ No deletions" + : "✅ Zones deleted (\(event.deletions.count): " + + event.deletions + .map { $0.zoneID.zoneName } + .sorted() + .joined(separator: ", ") + debug( + """ + \(prefix) fetchedDatabaseChanges + \(deletions) + """ + ) + case .fetchedRecordZoneChanges(let event): + let deletionsByZoneName = Dictionary( + grouping: event.deletions, + by: \.recordID.zoneID.zoneName + ) + let zoneDeletions = deletionsByZoneName.keys.sorted() + .map { zoneName in "\(zoneName) (\(deletionsByZoneName[zoneName]!.count))" } + .joined(separator: ", ") + let deletions = + event.deletions.isEmpty + ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(zoneDeletions)" + + let modificationsByZoneName = Dictionary( + grouping: event.modifications, + by: \.record.recordID.zoneID.zoneName + ) + let zoneModifications = modificationsByZoneName.keys.sorted() + .map { zoneName in "\(zoneName) (\(modificationsByZoneName[zoneName]!.count))" } + .joined(separator: ", ") + let modifications = + event.modifications.isEmpty + ? "⚪️ No modifications" + : "✅ Records modified (\(event.modifications.count)): \(zoneModifications)" + + debug( + """ + \(prefix) fetchedRecordZoneChanges + \(modifications) + \(deletions) + """ + ) + case .sentDatabaseChanges(let event): + let savedZoneNames = event.savedZones + .map { $0.zoneID.zoneName } + .sorted() + .joined(separator: ", ") + let savedZones = + event.savedZones.isEmpty + ? "⚪️ No saved zones" : "✅ Saved zones (\(event.savedZones.count)): \(savedZoneNames)" + + let deletedZoneNames = event.deletedZoneIDs + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let deletedZones = + event.deletedZoneIDs.isEmpty + ? "⚪️ No deleted zones" + : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" + + let failedZoneSaveNames = event.failedZoneSaves + .map { $0.zone.zoneID.zoneName } + .sorted() + .joined(separator: ", ") + let failedZoneSaves = + event.failedZoneSaves.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(event.failedZoneSaves.count)): \(failedZoneSaveNames)" + + let failedZoneDeleteNames = event.failedZoneDeletes + .keys + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let failedZoneDeletes = + event.failedZoneDeletes.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" + + debug( + """ + \(prefix) sentDatabaseChanges + \(savedZones) + \(deletedZones) + \(failedZoneSaves) + \(failedZoneDeletes) + """ + ) + case .sentRecordZoneChanges(let event): + let savedRecordsByZoneName = Dictionary( + grouping: event.savedRecords, + by: \.recordID.zoneID.zoneName + ) + let savedRecords = savedRecordsByZoneName.keys + .sorted() + .map { "\($0) (\(savedRecordsByZoneName[$0]!.count))" } + .joined(separator: ",") + + let deletedRecordsByZoneName = Dictionary( + grouping: event.deletedRecordIDs, + by: \.zoneID.zoneName + ) + let deletedRecords = deletedRecordsByZoneName.keys + .sorted() + .map { "\($0) (\(deletedRecordsByZoneName[$0]!.count))" } + .joined(separator: ",") + + let failedRecordSavesByZoneName = Dictionary( + grouping: event.failedRecordSaves, + by: \.record.recordID.zoneID.zoneName + ) + let failedRecordSaves = failedRecordSavesByZoneName.keys + .sorted() + .map { "\($0) (\(failedRecordSavesByZoneName[$0]!.count))" } + .joined(separator: ", ") + + let failedRecordDeletesByZoneName = Dictionary( + grouping: event.failedRecordDeletes.keys, + by: \.zoneID.zoneName + ) + let failedRecordDeletes = failedRecordDeletesByZoneName.keys + .sorted() + .map { "\($0) (\(failedRecordDeletesByZoneName[$0]!.count))" } + .joined(separator: ", ") + + debug( + """ + \(prefix) sentRecordZoneChanges + \(savedRecordsByZoneName.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") + \(deletedRecordsByZoneName.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records: \(deletedRecords)") + \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)") + \(failedRecordDeletesByZoneName.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete: \(failedRecordDeletes)") + """ + ) + case .willFetchChanges(let event): + if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { + debug("\(prefix) willFetchChanges: \(event.context.reason.description)") + } else { + debug("\(prefix) willFetchChanges") + } + case .willFetchRecordZoneChanges(let event): + debug("\(prefix) willFetchRecordZoneChanges: \(event.zoneID.zoneName)") + case .didFetchRecordZoneChanges(let event): + let errorType = event.error.map { + switch $0.code { + case .internalError: "internalError" + case .partialFailure: "partialFailure" + case .networkUnavailable: "networkUnavailable" + case .networkFailure: "networkFailure" + case .badContainer: "badContainer" + case .serviceUnavailable: "serviceUnavailable" + case .requestRateLimited: "requestRateLimited" + case .missingEntitlement: "missingEntitlement" + case .notAuthenticated: "notAuthenticated" + case .permissionFailure: "permissionFailure" + case .unknownItem: "unknownItem" + case .invalidArguments: "invalidArguments" + case .resultsTruncated: "resultsTruncated" + case .serverRecordChanged: "serverRecordChanged" + case .serverRejectedRequest: "serverRejectedRequest" + case .assetFileNotFound: "assetFileNotFound" + case .assetFileModified: "assetFileModified" + case .incompatibleVersion: "incompatibleVersion" + case .constraintViolation: "constraintViolation" + case .operationCancelled: "operationCancelled" + case .changeTokenExpired: "changeTokenExpired" + case .batchRequestFailed: "batchRequestFailed" + case .zoneBusy: "zoneBusy" + case .badDatabase: "badDatabase" + case .quotaExceeded: "quotaExceeded" + case .zoneNotFound: "zoneNotFound" + case .limitExceeded: "limitExceeded" + case .userDeletedZone: "userDeletedZone" + case .tooManyParticipants: "tooManyParticipants" + case .alreadyShared: "alreadyShared" + case .referenceViolation: "referenceViolation" + case .managedAccountRestricted: "managedAccountRestricted" + case .participantMayNeedVerification: "participantMayNeedVerification" + case .serverResponseLost: "serverResponseLost" + case .assetNotAvailable: "assetNotAvailable" + case .accountTemporarilyUnavailable: "accountTemporarilyUnavailable" + @unknown default: "unknown" + } + } + let error = errorType.map { "\n ❌ \($0)" } ?? "" + debug( + """ + \(prefix) willFetchRecordZoneChanges + ✅ Zone: \(event.zoneID.zoneName)\(error) + """ + ) + case .didFetchChanges(let event): + if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { + debug("\(prefix) didFetchChanges: \(event.context.reason.description)") + } else { + debug("\(prefix) didFetchChanges") + } + case .willSendChanges(let event): + debug("\(prefix) willSendChanges: \(event.context.reason.description)") + case .didSendChanges(let event): + debug("\(prefix) didSendChanges: \(event.context.reason.description)") + @unknown default: + warning("\(prefix) ⚠️ unknown event: \(event.description)") + } + } +} diff --git a/db1.sqlite b/db1.sqlite index a7c656171f573e7eedacad726461ee4730b60b3d..14cc60f35c60dfcb318317b060df26b478a60fdd 100644 GIT binary patch delta 82 zcmZojXh@hK&B!`Y#+i|IW5N=7Hb(w44E$#{3kn?KH&J6`3bn8>G_^1=Pt!F|HMY<- cF*i-pwMa`c*EKavHAzb~u`sbPvSdI40D0CDEC2ui delta 29 lcmZojXh@hK&B#1a#+i|MW5N=7W(NK*n*{}~@=t6~0RV>82`c~q From c2eea008e2a1c1a8b1bbb331dbc1a7af9f976980 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 17 May 2025 20:48:19 -0600 Subject: [PATCH 047/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 198 ++++++++++-------- Tests/SharingGRDBTests/CloudKitTests.swift | 69 ++++++ 2 files changed, 182 insertions(+), 85 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 70cbcdab..22224256 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -14,10 +14,11 @@ extension DependencyValues { public final actor SyncEngine { nonisolated let container: CKContainer nonisolated let database: any DatabaseWriter - nonisolated let tables: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] lazy var metadatabase: any DatabaseWriter = try! DatabaseQueue() - var stateSerialization: CKSyncEngine.State.Serialization? - lazy var underlyingSyncEngine: CKSyncEngine = defaultSyncEngine + private var metadatabaseURL: URL + //var stateSerialization: CKSyncEngine.State.Serialization? + nonisolated let tables: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + lazy var underlyingSyncEngine: any CKSyncEngineProtocol = defaultSyncEngine public init( container: CKContainer, @@ -33,6 +34,7 @@ public final actor SyncEngine { ) self.container = container self.database = database + self.metadatabaseURL = URL.metadatabase(container: container) self.tables = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) Task { await withErrorReporting(.sharingGRDBCloudKitFailure) { @@ -42,7 +44,7 @@ public final actor SyncEngine { } func setUpSyncEngine() throws { - defer { underlyingSyncEngine = defaultSyncEngine } + defer { _ = underlyingSyncEngine } metadatabase = try defaultMetadatabase var migrator = DatabaseMigrator() @@ -83,7 +85,7 @@ public final actor SyncEngine { } try migrator.migrate(metadatabase) let previousZones = try metadatabase.read { db in - stateSerialization = try StateSerialization.all.fetchOne(db)?.data + //stateSerialization = try StateSerialization.all.fetchOne(db)?.data return try Zone.all.fetchAll(db) } let currentZones = try database.read { db in @@ -414,7 +416,7 @@ public final actor SyncEngine { } public func fetchChanges() async throws { - try await underlyingSyncEngine.fetchChanges(.init(scope: .all, operationGroup: nil)) + try await underlyingSyncEngine.fetchChanges() } public func deleteLocalData() throws { @@ -435,7 +437,7 @@ public final actor SyncEngine { } func didUpdate(recordName: String, zoneName: String) { - underlyingSyncEngine.state.add( + underlyingSyncEngine.engineState.add( pendingRecordZoneChanges: [ .saveRecord( CKRecord.ID( @@ -448,7 +450,7 @@ public final actor SyncEngine { } func willDelete(recordName: String, zoneName: String) { - underlyingSyncEngine.state.add( + underlyingSyncEngine.engineState.add( pendingRecordZoneChanges: [ .deleteRecord( CKRecord.ID( @@ -460,10 +462,6 @@ public final actor SyncEngine { ) } - private var metadatabaseURL: URL { - URL.metadatabase(container: container) - } - private var defaultMetadatabase: any DatabaseWriter { get throws { var configuration = Configuration() @@ -493,7 +491,9 @@ public final actor SyncEngine { CKSyncEngine( CKSyncEngine.Configuration( database: container.privateCloudDatabase, - stateSerialization: stateSerialization, + stateSerialization: try? database.read { db in + try StateSerialization.all.fetchOne(db)?.data + }, delegate: self ) ) @@ -509,7 +509,7 @@ extension SyncEngine: CKSyncEngineDelegate { case .accountChange(let event): handleAccountChange(event) case .stateUpdate(let event): - stateSerialization = event.stateSerialization + //stateSerialization = event.stateSerialization withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in try StateSerialization.insert( @@ -543,55 +543,55 @@ extension SyncEngine: CKSyncEngineDelegate { else { return nil } #if DEBUG - struct State { - var missingTables: [CKRecord.ID] = [] - var missingRecords: [CKRecord.ID] = [] - var sentRecords: [CKRecord.ID] = [] - } - let state = LockIsolated(State()) - defer { - let state = state.withValue(\.self) - let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - logger.debug( - """ - SharingGRDB: nextRecordZoneChangeBatch: \(context.reason) - \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") - \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") - \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") - """ - ) - } + struct State { + var missingTables: [CKRecord.ID] = [] + var missingRecords: [CKRecord.ID] = [] + var sentRecords: [CKRecord.ID] = [] + } + let state = LockIsolated(State()) + defer { + let state = state.withValue(\.self) + let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + logger.debug( + """ + SharingGRDB: nextRecordZoneChangeBatch: \(context.reason) + \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") + \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") + \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") + """ + ) + } #endif let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in #if DEBUG - var missingTable: CKRecord.ID? - var missingRecord: CKRecord.ID? - var sentRecord: CKRecord.ID? - defer { - state.withValue { [missingTable, missingRecord, sentRecord] in - if let missingTable { $0.missingTables.append(missingTable) } - if let missingRecord { $0.missingRecords.append(missingRecord) } - if let sentRecord { $0.sentRecords.append(sentRecord) } + var missingTable: CKRecord.ID? + var missingRecord: CKRecord.ID? + var sentRecord: CKRecord.ID? + defer { + state.withValue { [missingTable, missingRecord, sentRecord] in + if let missingTable { $0.missingTables.append(missingTable) } + if let missingRecord { $0.missingRecords.append(missingRecord) } + if let sentRecord { $0.sentRecords.append(sentRecord) } + } } - } #endif let metadata = await metadataFor(recordID: recordID) @@ -640,7 +640,7 @@ extension SyncEngine: CKSyncEngineDelegate { switch event.changeType { case .signIn: for table in tables.values { - underlyingSyncEngine.state.add( + underlyingSyncEngine.engineState.add( pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneName: table.tableName))] ) withErrorReporting(.sharingGRDBCloudKitFailure) { @@ -652,7 +652,7 @@ extension SyncEngine: CKSyncEngineDelegate { } return try open(table) } - underlyingSyncEngine.state.add( + underlyingSyncEngine.engineState.add( pendingRecordZoneChanges: names.map { .saveRecord( CKRecord.ID( @@ -733,8 +733,8 @@ extension SyncEngine: CKSyncEngineDelegate { var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] defer { - underlyingSyncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) - underlyingSyncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + underlyingSyncEngine.engineState.add(pendingDatabaseChanges: newPendingDatabaseChanges) + underlyingSyncEngine.engineState.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } for failedRecordSave in event.failedRecordSaves { let failedRecord = failedRecordSave.record @@ -1109,13 +1109,13 @@ extension Logger { } case .fetchedDatabaseChanges(let event): let deletions = - event.deletions.isEmpty - ? "⚪️ No deletions" - : "✅ Zones deleted (\(event.deletions.count): " - + event.deletions - .map { $0.zoneID.zoneName } - .sorted() - .joined(separator: ", ") + event.deletions.isEmpty + ? "⚪️ No deletions" + : "✅ Zones deleted (\(event.deletions.count): " + + event.deletions + .map { $0.zoneID.zoneName } + .sorted() + .joined(separator: ", ") debug( """ \(prefix) fetchedDatabaseChanges @@ -1131,8 +1131,8 @@ extension Logger { .map { zoneName in "\(zoneName) (\(deletionsByZoneName[zoneName]!.count))" } .joined(separator: ", ") let deletions = - event.deletions.isEmpty - ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(zoneDeletions)" + event.deletions.isEmpty + ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(zoneDeletions)" let modificationsByZoneName = Dictionary( grouping: event.modifications, @@ -1142,9 +1142,9 @@ extension Logger { .map { zoneName in "\(zoneName) (\(modificationsByZoneName[zoneName]!.count))" } .joined(separator: ", ") let modifications = - event.modifications.isEmpty - ? "⚪️ No modifications" - : "✅ Records modified (\(event.modifications.count)): \(zoneModifications)" + event.modifications.isEmpty + ? "⚪️ No modifications" + : "✅ Records modified (\(event.modifications.count)): \(zoneModifications)" debug( """ @@ -1159,26 +1159,26 @@ extension Logger { .sorted() .joined(separator: ", ") let savedZones = - event.savedZones.isEmpty - ? "⚪️ No saved zones" : "✅ Saved zones (\(event.savedZones.count)): \(savedZoneNames)" + event.savedZones.isEmpty + ? "⚪️ No saved zones" : "✅ Saved zones (\(event.savedZones.count)): \(savedZoneNames)" let deletedZoneNames = event.deletedZoneIDs .map { $0.zoneName } .sorted() .joined(separator: ", ") let deletedZones = - event.deletedZoneIDs.isEmpty - ? "⚪️ No deleted zones" - : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" + event.deletedZoneIDs.isEmpty + ? "⚪️ No deleted zones" + : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" let failedZoneSaveNames = event.failedZoneSaves .map { $0.zone.zoneID.zoneName } .sorted() .joined(separator: ", ") let failedZoneSaves = - event.failedZoneSaves.isEmpty - ? "⚪️ No failed saved zones" - : "🛑 Failed zone saves (\(event.failedZoneSaves.count)): \(failedZoneSaveNames)" + event.failedZoneSaves.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(event.failedZoneSaves.count)): \(failedZoneSaveNames)" let failedZoneDeleteNames = event.failedZoneDeletes .keys @@ -1186,9 +1186,9 @@ extension Logger { .sorted() .joined(separator: ", ") let failedZoneDeletes = - event.failedZoneDeletes.isEmpty - ? "⚪️ No failed saved zones" - : "🛑 Failed zone saves (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" + event.failedZoneDeletes.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" debug( """ @@ -1317,3 +1317,31 @@ extension Logger { } } } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package protocol CKSyncEngineProtocol: Sendable { + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws + var engineState: any CKSyncEngineStateProtocol { get } +} +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKSyncEngineProtocol { + package func fetchChanges() async throws { + try await fetchChanges(CKSyncEngine.FetchChangesOptions()) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package protocol CKSyncEngineStateProtocol: Sendable { + func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) + func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) + func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) + func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKSyncEngine: CKSyncEngineProtocol { + package var engineState: any CKSyncEngineStateProtocol { state } +} +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKSyncEngine.State: CKSyncEngineStateProtocol { +} diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift new file mode 100644 index 00000000..0bfd6c05 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests.swift @@ -0,0 +1,69 @@ +import CloudKit +import SharingGRDB +import Testing + +@Suite +struct CloudKitTests { + let database: any DatabaseWriter + let _syncEngine: Any + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var syncEngine: SyncEngine { + _syncEngine as! SyncEngine + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() throws { + self.database = try SharingGRDBTests.database() + _syncEngine = SyncEngine( + container: CKContainer(identifier: "CloudKit-Anonymous.tests"), + database: database, + tables: [Reminder.self, RemindersList.self] + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func setUpSyncEngine() throws { + + } +} + +@Table private struct Reminder: Identifiable { + let id: Int + var title = "" + var remindersListID: RemindersList.ID +} +@Table private struct RemindersList: Identifiable { + let id: Int + var title = "" +} + +private func database() throws -> any DatabaseWriter { + var configuration = Configuration() + configuration.foreignKeysEnabled = false + let database = try DatabaseQueue(configuration: configuration) + var migrator = DatabaseMigrator() + migrator.registerMigration("Create tables") { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "reminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) + } + try migrator.migrate(database) + return database +} From cf67a5919b1e46cb2745e86a23b16c6db8c9a136 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 18 May 2025 12:27:12 -0700 Subject: [PATCH 048/581] a basic setup teardown test --- Package.resolved | 6 +- Package.swift | 3 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 113 ++++++--- Tests/SharingGRDBTests/CloudKitTests.swift | 218 +++++++++++++++++- 4 files changed, 296 insertions(+), 44 deletions(-) diff --git a/Package.resolved b/Package.resolved index b4b90afd..6185be56 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6af43fe820ccae2bb136d800d909d9dbc414ddf4d36d59981ccea0db4061c24a", + "originHash" : "38cec5b19c216356c887ee297b6b96ab39aaff86c3817f996d0520564576cb23", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "71657e2f1d5b5af29e8cc5c450a67523433671b1", - "version" : "0.2.0" + "branch" : "default-date-uuid-representations", + "revision" : "0194507ff6651d6d406c727f54cd7f332155cadd" } }, { diff --git a/Package.swift b/Package.swift index f4bb480c..ddf6b3bf 100644 --- a/Package.swift +++ b/Package.swift @@ -33,6 +33,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.0.0"), //.package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.2.0"), .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "default-date-uuid-representations"), ], @@ -57,6 +58,8 @@ let package = Package( dependencies: [ "SharingGRDB", .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), .product(name: "StructuredQueries", package: "swift-structured-queries"), ] ), diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 22224256..1ae5d768 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -12,18 +12,54 @@ extension DependencyValues { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final actor SyncEngine { - nonisolated let container: CKContainer nonisolated let database: any DatabaseWriter lazy var metadatabase: any DatabaseWriter = try! DatabaseQueue() - private var metadatabaseURL: URL - //var stateSerialization: CKSyncEngine.State.Serialization? + private var metadatabaseURL: URL? nonisolated let tables: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] - lazy var underlyingSyncEngine: any CKSyncEngineProtocol = defaultSyncEngine + var underlyingSyncEngine: (any CKSyncEngineProtocol)! // = defaultSyncEngine + let defaultSyncEngine: (SyncEngine) -> any CKSyncEngineProtocol public init( container: CKContainer, database: any DatabaseWriter, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + ) { + self.init( + defaultSyncEngine: { syncEngine in + CKSyncEngine( + CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: try? database.read { db in + try StateSerialization.all.fetchOne(db)?.data + }, + delegate: syncEngine + ) + ) + }, + database: database, + metadatabaseURL: URL.metadatabase(container: container), + tables: tables + ) + } + + package init( + defaultSyncEngine: any CKSyncEngineProtocol, + database: any DatabaseWriter, + tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + ) { + self.init( + defaultSyncEngine: { _ in defaultSyncEngine }, + database: database, + metadatabaseURL: nil, + tables: tables + ) + } + + private init( + defaultSyncEngine: @escaping (SyncEngine) -> any CKSyncEngineProtocol, + database: any DatabaseWriter, + metadatabaseURL: URL?, + tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { // TODO: Explain why / link to documentation? precondition( @@ -32,9 +68,9 @@ public final actor SyncEngine { Foreign key support must be disabled to synchronize with CloudKit. """ ) - self.container = container + self.defaultSyncEngine = defaultSyncEngine self.database = database - self.metadatabaseURL = URL.metadatabase(container: container) + self.metadatabaseURL = metadatabaseURL self.tables = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) Task { await withErrorReporting(.sharingGRDBCloudKitFailure) { @@ -44,7 +80,7 @@ public final actor SyncEngine { } func setUpSyncEngine() throws { - defer { _ = underlyingSyncEngine } + defer { underlyingSyncEngine = defaultSyncEngine(self) } metadatabase = try defaultMetadatabase var migrator = DatabaseMigrator() @@ -126,10 +162,17 @@ public final actor SyncEngine { } } try database.write { db in - try SQLQueryExpression( - "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" - ) - .execute(db) + if let metadatabaseURL { + try SQLQueryExpression( + "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" + ) + .execute(db) + } else { + try SQLQueryExpression( + "ATTACH DATABASE 'file:metadatabase?mode=memory&cache=shared' AS \(quote: .sharingGRDBCloudKitSchemaName)" + ) + .execute(db) + } db.add(function: .areTriggersEnabled) db.add(function: .didUpdate) db.add(function: .willDelete) @@ -305,7 +348,7 @@ public final actor SyncEngine { } } - func tearDownSyncEngine() throws { + package func tearDownSyncEngine() throws { try database.write { db in for table in tables.values { func open(_: T.Type) throws { @@ -346,7 +389,7 @@ public final actor SyncEngine { continue } - switch foreignKey.onDelete { + switch foreignKey.onUpdate { case .cascade: try SQLQueryExpression( """ @@ -406,13 +449,15 @@ public final actor SyncEngine { db.remove(function: .didUpdate) db.remove(function: .areTriggersEnabled) } - try database.write { db in + try database.read { db in try SQLQueryExpression( "DETACH DATABASE \(quote: .sharingGRDBCloudKitSchemaName)" ) .execute(db) } - try FileManager.default.removeItem(at: metadatabaseURL) + if let metadatabaseURL { + try FileManager.default.removeItem(at: metadatabaseURL) + } } public func fetchChanges() async throws { @@ -470,34 +515,26 @@ public final actor SyncEngine { logger.trace("\($0.expandedDescription)") } } - logger.debug( + if let metadatabaseURL { + logger.debug( """ SharingGRDB: Metadatabase connection: - open "\(self.metadatabaseURL.path(percentEncoded: false))" + open "\(metadatabaseURL.path(percentEncoded: false))" """ - ) - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - return try DatabaseQueue( - path: metadatabaseURL.path(percentEncoded: false), - configuration: configuration - ) + ) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + return try DatabaseQueue( + path: metadatabaseURL.path(percentEncoded: false), + configuration: configuration + ) + } else { + return try DatabaseQueue(named: "metadatabase", configuration: configuration) + } } } - - private var defaultSyncEngine: CKSyncEngine { - CKSyncEngine( - CKSyncEngine.Configuration( - database: container.privateCloudDatabase, - stateSerialization: try? database.read { db in - try StateSerialization.all.fetchOne(db)?.data - }, - delegate: self - ) - ) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift index 0bfd6c05..4aa7464a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests.swift @@ -1,8 +1,10 @@ import CloudKit +import InlineSnapshotTesting import SharingGRDB +import SnapshotTestingCustomDump import Testing -@Suite +@Suite(.snapshots(record: .failed)) struct CloudKitTests { let database: any DatabaseWriter let _syncEngine: Any @@ -16,15 +18,205 @@ struct CloudKitTests { init() throws { self.database = try SharingGRDBTests.database() _syncEngine = SyncEngine( - container: CKContainer(identifier: "CloudKit-Anonymous.tests"), + defaultSyncEngine: MockSyncEngine(engineState: MockSyncEngineState()), database: database, tables: [Reminder.self, RemindersList.self] ) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func setUpSyncEngine() throws { + @Test func setUpAndTearDown() async throws { + try await Task.sleep(for: .seconds(0.1)) + try await database.read { db in + assertInlineSnapshot( + of: try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db), + as: .customDump + ) { + #""" + [ + [0]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_insert_remindersLists" + AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'remindersLists' + ) + WHERE areTriggersEnabled(); + END + """, + [1]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_update_remindersLists" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'remindersLists' + ) + WHERE areTriggersEnabled(); + END + """, + [2]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_delete_remindersLists" + BEFORE DELETE ON "remindersLists" FOR EACH ROW BEGIN + SELECT willDelete( + "old"."id", + 'remindersLists' + ) + WHERE areTriggersEnabled(); + END + """, + [3]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataInserts" + AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName", "userModificationDate") + SELECT + 'remindersLists', + "new"."id", + datetime('subsec') + WHERE areTriggersEnabled() + ON CONFLICT("zoneName", "recordName") DO NOTHING; + END + """, + [4]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataUpdates" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName") + SELECT 'remindersLists', "new"."id" + WHERE areTriggersEnabled() + ON CONFLICT("zoneName", "recordName") DO UPDATE SET + "userModificationDate" = datetime('subsec'); + END + """, + [5]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_insert_reminders" + AFTER INSERT ON "reminders" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'reminders' + ) + WHERE areTriggersEnabled(); + END + """, + [6]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_update_reminders" + AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'reminders' + ) + WHERE areTriggersEnabled(); + END + """, + [7]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_delete_reminders" + BEFORE DELETE ON "reminders" FOR EACH ROW BEGIN + SELECT willDelete( + "old"."id", + 'reminders' + ) + WHERE areTriggersEnabled(); + END + """, + [8]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataInserts" + AFTER INSERT ON "reminders" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName", "userModificationDate") + SELECT + 'reminders', + "new"."id", + datetime('subsec') + WHERE areTriggersEnabled() + ON CONFLICT("zoneName", "recordName") DO NOTHING; + END + """, + [9]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataUpdates" + AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName") + SELECT 'reminders', "new"."id" + WHERE areTriggersEnabled() + ON CONFLICT("zoneName", "recordName") DO UPDATE SET + "userModificationDate" = datetime('subsec'); + END + """, + [10]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_remindersLists_onDeleteCascade" + AFTER DELETE ON "remindersLists" + FOR EACH ROW BEGIN + DELETE FROM "reminders" + WHERE "remindersListID" = "old"."id"; + END + """ + ] + """# + } + } + + try await syncEngine.tearDownSyncEngine() + try await database.read { db in + assertInlineSnapshot( + of: try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db), + as: .customDump + ) { + """ + [] + """ + } + } + } + + @Test func inMemoryAttachment() throws { + print(#function) + let d1 = try DatabaseQueue(named: "d1") + let d1_ = try DatabaseQueue(named: "d1") + let d2 = try DatabaseQueue(named: "d2") + + try d1.write { db in + try #sql("create table t1 (id integer)").execute(db) + try #sql("insert into t1 (id) values (1), (2), (3)").execute(db) + } + try d2.write { db in + try #sql("create table t2 (id integer)").execute(db) + try #sql("insert into t2 (id) values (10), (20), (30)").execute(db) + } + try d1_.read { db in + try #expect(#sql("select id from t1", as: Int.self).fetchAll(db) == [1, 2, 3]) + } + try d2.read { db in + try #expect(#sql("select id from t2", as: Int.self).fetchAll(db) == [10, 20, 30]) + } + + try d2.write { db in + try #sql("attach database 'file:d1?mode=memory&cache=shared' as 'd1'").execute(db) + } + try d2.read { db in + try #expect(#sql("select id from d1.t1", as: Int.self).fetchAll(db) == [1, 2, 3]) + try #expect(#sql("select id from t2", as: Int.self).fetchAll(db) == [10, 20, 30]) + } + try d2.read { db in + try #sql("DETACH DATABASE d1").execute(db) + } + try d2.write { db in + withKnownIssue { + try #expect(#sql("select id from d1.t1", as: Int.self).fetchAll(db) == [1, 2, 3]) + } + try #expect(#sql("select id from t2", as: Int.self).fetchAll(db) == [10, 20, 30]) + } + } + + @Test func detatchFromWriteProblem() throws { + print(#function) + let d2 = try DatabaseQueue(named: "d2") + try d2.write { db in + try #sql("attach database 'file:d1?mode=memory&cache=shared' as 'd1'").execute(db) + } + try d2.write { db in + try #sql("DETACH DATABASE d1").execute(db) + } } } @@ -41,6 +233,9 @@ struct CloudKitTests { private func database() throws -> any DatabaseWriter { var configuration = Configuration() configuration.foreignKeysEnabled = false + configuration.prepareDatabase { db in + db.trace { print($0) } + } let database = try DatabaseQueue(configuration: configuration) var migrator = DatabaseMigrator() migrator.registerMigration("Create tables") { db in @@ -67,3 +262,20 @@ private func database() throws -> any DatabaseWriter { try migrator.migrate(database) return database } + +struct MockSyncEngine: CKSyncEngineProtocol { + var engineState: any SharingGRDBCore.CKSyncEngineStateProtocol + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { + } +} + +struct MockSyncEngineState: CKSyncEngineStateProtocol { + func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + } + func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + } + func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + } + func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + } +} From b6c672e1f24175c909e94ca3c2c68ce3221e8975 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 19 May 2025 11:40:37 -0700 Subject: [PATCH 049/581] wip --- Tests/SharingGRDBTests/CloudKitTests.swift | 57 +++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift index 4aa7464a..04e75d1e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests.swift @@ -2,12 +2,15 @@ import CloudKit import InlineSnapshotTesting import SharingGRDB import SnapshotTestingCustomDump +import ConcurrencyExtras import Testing @Suite(.snapshots(record: .failed)) struct CloudKitTests { let database: any DatabaseWriter - let _syncEngine: Any + let _syncEngine: any Sendable + let underlyingSyncEngine: MockSyncEngine + let underlyingSyncState: MockSyncEngineState @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var syncEngine: SyncEngine { @@ -15,13 +18,16 @@ struct CloudKitTests { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() throws { + init() async throws { + underlyingSyncState = MockSyncEngineState() + underlyingSyncEngine = MockSyncEngine(engineState: underlyingSyncState) self.database = try SharingGRDBTests.database() _syncEngine = SyncEngine( - defaultSyncEngine: MockSyncEngine(engineState: MockSyncEngineState()), + defaultSyncEngine: underlyingSyncEngine, database: database, tables: [Reminder.self, RemindersList.self] ) + try await Task.sleep(for: .seconds(0.1)) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -169,6 +175,17 @@ struct CloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insert() async throws { + try await database.write { db in + try RemindersList + .insert(RemindersList.Draft(title: "Personal")) + .execute(db) + } + try await Task.sleep(for: .seconds(1)) + #expect(underlyingSyncState.pendingRecordZoneChanges == []) + } + @Test func inMemoryAttachment() throws { print(#function) let d1 = try DatabaseQueue(named: "d1") @@ -263,19 +280,47 @@ private func database() throws -> any DatabaseWriter { return database } -struct MockSyncEngine: CKSyncEngineProtocol { - var engineState: any SharingGRDBCore.CKSyncEngineStateProtocol +final class MockSyncEngine: CKSyncEngineProtocol { + let _engineState: LockIsolated + init(engineState: any CKSyncEngineStateProtocol) { + self._engineState = LockIsolated(engineState) + } + var engineState: any CKSyncEngineStateProtocol { + _engineState.withValue(\.self) + } func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { } } -struct MockSyncEngineState: CKSyncEngineStateProtocol { +final class MockSyncEngineState: CKSyncEngineStateProtocol { + private let _pendingRecordZoneChanges = LockIsolated<[CKSyncEngine.PendingRecordZoneChange]>([]) + private let _pendingDatabaseChanges = LockIsolated<[CKSyncEngine.PendingDatabaseChange]>([]) + + var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { + _pendingRecordZoneChanges.withValue(\.self) + } + var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { + _pendingDatabaseChanges.withValue(\.self) + } + func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.append(contentsOf: pendingRecordZoneChanges) + } } func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.removeAll(where: pendingRecordZoneChanges.contains) + } } func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.append(contentsOf: pendingDatabaseChanges) + } } func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.removeAll(where: pendingDatabaseChanges.contains) + } } } From f4fc9b2dcedf22c4ce56816519f59830de23f698 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 17 May 2025 19:37:40 -0600 Subject: [PATCH 050/581] some improved logging --- .gitignore | 1 + Examples/Reminders/Schema.swift | 5 +- Package.resolved | 6 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 319 +++++++++++++++++- 4 files changed, 319 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 71d4f9f3..9817718f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ xcuserdata/ DerivedData/ .swiftpm .netrc +.sqlite \ No newline at end of file diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 0b0ed0c4..2eb7cee5 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -97,15 +97,16 @@ func appDatabase() throws -> any DatabaseWriter { if context == .preview { print("\($0.expandedDescription)") } else { - logger.debug("\($0.expandedDescription)") + logger.trace("\($0.expandedDescription)") } } #endif } if context == .live { let path = URL.documentsDirectory.appending(component: "db.sqlite").path(percentEncoded: false) - logger.info( + logger.debug( """ + App database open "\(path)" """ ) diff --git a/Package.resolved b/Package.resolved index b4b90afd..ab3b67d5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6af43fe820ccae2bb136d800d909d9dbc414ddf4d36d59981ccea0db4061c24a", + "originHash" : "bdaf5eba51a649b468935fd4d0b8a1d354139c8612a331feb6f061340c301e01", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "71657e2f1d5b5af29e8cc5c450a67523433671b1", - "version" : "0.2.0" + "branch" : "default-date-uuid-representations", + "revision" : "88e578aae010571fcceff743465f2bc8b15dd89e" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 29bcf0ed..7bf1290f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1,4 +1,5 @@ import CloudKit +import ConcurrencyExtras import OSLog @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -454,7 +455,7 @@ public final actor SyncEngine { } public func fetchChanges() async throws { - try await underlyingSyncEngine.fetchChanges() + try await underlyingSyncEngine.fetchChanges(.init(scope: .all, operationGroup: nil)) } public func deleteLocalData() throws { @@ -509,11 +510,12 @@ public final actor SyncEngine { var configuration = Configuration() configuration.prepareDatabase { db in db.trace { - logger.debug("\($0.expandedDescription)") + logger.trace("\($0.expandedDescription)") } } - logger.info( + logger.debug( """ + SharingGRDB: Metadatabase connection: open "\(self.metadatabaseURL.path(percentEncoded: false))" """ ) @@ -542,7 +544,7 @@ public final actor SyncEngine { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - logger.debug("handleEvent: \(event)") + logger.log(event) switch event { case .accountChange(let event): @@ -569,7 +571,7 @@ extension SyncEngine: CKSyncEngineDelegate { .didFetchChanges, .willSendChanges, .didSendChanges: break @unknown default: - logger.warning("Sync engine received unknown event: \(event)") + break } } @@ -577,15 +579,68 @@ extension SyncEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - logger.debug("nextRecordZoneChangeBatch: \(context)") - let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) + guard !changes.isEmpty + else { return nil } + + #if DEBUG + struct State { + var missingTables: [CKRecord.ID] = [] + var missingRecords: [CKRecord.ID] = [] + var sentRecords: [CKRecord.ID] = [] + } + let state = LockIsolated(State()) + defer { + let state = state.withValue(\.self) + let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + logger.debug( + """ + SharingGRDB: nextRecordZoneChangeBatch: \(context.reason) + \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") + \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") + \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") + """ + ) + } + #endif + let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in + #if DEBUG + var missingTable: CKRecord.ID? + var missingRecord: CKRecord.ID? + var sentRecord: CKRecord.ID? + defer { + state.withValue { [missingTable, missingRecord, sentRecord] in + if let missingTable { $0.missingTables.append(missingTable) } + if let missingRecord { $0.missingRecords.append(missingRecord) } + if let sentRecord { $0.sentRecords.append(sentRecord) } + } + } + #endif + let metadata = await metadataFor(recordID: recordID) guard let table = tables[recordID.zoneID.zoneName] else { reportIssue("") syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingTable = recordID return nil } func open(_: T.Type) async -> CKRecord? { @@ -599,6 +654,7 @@ extension SyncEngine: CKSyncEngineDelegate { guard let row else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingRecord = recordID return nil } @@ -613,6 +669,7 @@ extension SyncEngine: CKSyncEngineDelegate { userModificationDate: metadata?.userModificationDate ) await refreshLastKnownServerRecord(record) + sentRecord = recordID return record } return await open(table) @@ -670,6 +727,9 @@ extension SyncEngine: CKSyncEngineDelegate { open(table) } } + + // TODO: Deal with modifications? + _ = event.modifications } } } @@ -1053,3 +1113,248 @@ extension URL { @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Logger { + func log(_ event: CKSyncEngine.Event) { + let prefix = "SharingGRDB: handleEvent:" + switch event { + case .stateUpdate: + debug("\(prefix) stateUpdate") + case .accountChange(let event): + switch event.changeType { + case .signIn(let currentUser): + debug( + """ + \(prefix) signIn + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + case .signOut(let previousUser): + debug( + """ + \(prefix) signOut + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + """ + ) + case .switchAccounts(let previousUser, let currentUser): + debug( + """ + \(prefix) switchAccounts: + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + @unknown default: + debug("unknown") + } + case .fetchedDatabaseChanges(let event): + let deletions = + event.deletions.isEmpty + ? "⚪️ No deletions" + : "✅ Zones deleted (\(event.deletions.count): " + + event.deletions + .map { $0.zoneID.zoneName } + .sorted() + .joined(separator: ", ") + debug( + """ + \(prefix) fetchedDatabaseChanges + \(deletions) + """ + ) + case .fetchedRecordZoneChanges(let event): + let deletionsByZoneName = Dictionary( + grouping: event.deletions, + by: \.recordID.zoneID.zoneName + ) + let zoneDeletions = deletionsByZoneName.keys.sorted() + .map { zoneName in "\(zoneName) (\(deletionsByZoneName[zoneName]!.count))" } + .joined(separator: ", ") + let deletions = + event.deletions.isEmpty + ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(zoneDeletions)" + + let modificationsByZoneName = Dictionary( + grouping: event.modifications, + by: \.record.recordID.zoneID.zoneName + ) + let zoneModifications = modificationsByZoneName.keys.sorted() + .map { zoneName in "\(zoneName) (\(modificationsByZoneName[zoneName]!.count))" } + .joined(separator: ", ") + let modifications = + event.modifications.isEmpty + ? "⚪️ No modifications" + : "✅ Records modified (\(event.modifications.count)): \(zoneModifications)" + + debug( + """ + \(prefix) fetchedRecordZoneChanges + \(modifications) + \(deletions) + """ + ) + case .sentDatabaseChanges(let event): + let savedZoneNames = event.savedZones + .map { $0.zoneID.zoneName } + .sorted() + .joined(separator: ", ") + let savedZones = + event.savedZones.isEmpty + ? "⚪️ No saved zones" : "✅ Saved zones (\(event.savedZones.count)): \(savedZoneNames)" + + let deletedZoneNames = event.deletedZoneIDs + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let deletedZones = + event.deletedZoneIDs.isEmpty + ? "⚪️ No deleted zones" + : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" + + let failedZoneSaveNames = event.failedZoneSaves + .map { $0.zone.zoneID.zoneName } + .sorted() + .joined(separator: ", ") + let failedZoneSaves = + event.failedZoneSaves.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(event.failedZoneSaves.count)): \(failedZoneSaveNames)" + + let failedZoneDeleteNames = event.failedZoneDeletes + .keys + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let failedZoneDeletes = + event.failedZoneDeletes.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" + + debug( + """ + \(prefix) sentDatabaseChanges + \(savedZones) + \(deletedZones) + \(failedZoneSaves) + \(failedZoneDeletes) + """ + ) + case .sentRecordZoneChanges(let event): + let savedRecordsByZoneName = Dictionary( + grouping: event.savedRecords, + by: \.recordID.zoneID.zoneName + ) + let savedRecords = savedRecordsByZoneName.keys + .sorted() + .map { "\($0) (\(savedRecordsByZoneName[$0]!.count))" } + .joined(separator: ",") + + let deletedRecordsByZoneName = Dictionary( + grouping: event.deletedRecordIDs, + by: \.zoneID.zoneName + ) + let deletedRecords = deletedRecordsByZoneName.keys + .sorted() + .map { "\($0) (\(deletedRecordsByZoneName[$0]!.count))" } + .joined(separator: ",") + + let failedRecordSavesByZoneName = Dictionary( + grouping: event.failedRecordSaves, + by: \.record.recordID.zoneID.zoneName + ) + let failedRecordSaves = failedRecordSavesByZoneName.keys + .sorted() + .map { "\($0) (\(failedRecordSavesByZoneName[$0]!.count))" } + .joined(separator: ", ") + + let failedRecordDeletesByZoneName = Dictionary( + grouping: event.failedRecordDeletes.keys, + by: \.zoneID.zoneName + ) + let failedRecordDeletes = failedRecordDeletesByZoneName.keys + .sorted() + .map { "\($0) (\(failedRecordDeletesByZoneName[$0]!.count))" } + .joined(separator: ", ") + + debug( + """ + \(prefix) sentRecordZoneChanges + \(savedRecordsByZoneName.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") + \(deletedRecordsByZoneName.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records: \(deletedRecords)") + \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)") + \(failedRecordDeletesByZoneName.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete: \(failedRecordDeletes)") + """ + ) + case .willFetchChanges(let event): + if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { + debug("\(prefix) willFetchChanges: \(event.context.reason.description)") + } else { + debug("\(prefix) willFetchChanges") + } + case .willFetchRecordZoneChanges(let event): + debug("\(prefix) willFetchRecordZoneChanges: \(event.zoneID.zoneName)") + case .didFetchRecordZoneChanges(let event): + let errorType = event.error.map { + switch $0.code { + case .internalError: "internalError" + case .partialFailure: "partialFailure" + case .networkUnavailable: "networkUnavailable" + case .networkFailure: "networkFailure" + case .badContainer: "badContainer" + case .serviceUnavailable: "serviceUnavailable" + case .requestRateLimited: "requestRateLimited" + case .missingEntitlement: "missingEntitlement" + case .notAuthenticated: "notAuthenticated" + case .permissionFailure: "permissionFailure" + case .unknownItem: "unknownItem" + case .invalidArguments: "invalidArguments" + case .resultsTruncated: "resultsTruncated" + case .serverRecordChanged: "serverRecordChanged" + case .serverRejectedRequest: "serverRejectedRequest" + case .assetFileNotFound: "assetFileNotFound" + case .assetFileModified: "assetFileModified" + case .incompatibleVersion: "incompatibleVersion" + case .constraintViolation: "constraintViolation" + case .operationCancelled: "operationCancelled" + case .changeTokenExpired: "changeTokenExpired" + case .batchRequestFailed: "batchRequestFailed" + case .zoneBusy: "zoneBusy" + case .badDatabase: "badDatabase" + case .quotaExceeded: "quotaExceeded" + case .zoneNotFound: "zoneNotFound" + case .limitExceeded: "limitExceeded" + case .userDeletedZone: "userDeletedZone" + case .tooManyParticipants: "tooManyParticipants" + case .alreadyShared: "alreadyShared" + case .referenceViolation: "referenceViolation" + case .managedAccountRestricted: "managedAccountRestricted" + case .participantMayNeedVerification: "participantMayNeedVerification" + case .serverResponseLost: "serverResponseLost" + case .assetNotAvailable: "assetNotAvailable" + case .accountTemporarilyUnavailable: "accountTemporarilyUnavailable" + @unknown default: "unknown" + } + } + let error = errorType.map { "\n ❌ \($0)" } ?? "" + debug( + """ + \(prefix) willFetchRecordZoneChanges + ✅ Zone: \(event.zoneID.zoneName)\(error) + """ + ) + case .didFetchChanges(let event): + if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { + debug("\(prefix) didFetchChanges: \(event.context.reason.description)") + } else { + debug("\(prefix) didFetchChanges") + } + case .willSendChanges(let event): + debug("\(prefix) willSendChanges: \(event.context.reason.description)") + case .didSendChanges(let event): + debug("\(prefix) didSendChanges: \(event.context.reason.description)") + @unknown default: + warning("\(prefix) ⚠️ unknown event: \(event.description)") + } + } +} From e3854b9e029a9792cc974cc1dc4070ecc0fc0b38 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 19 May 2025 12:45:34 -0700 Subject: [PATCH 051/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 7bf1290f..65a51cd4 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -455,7 +455,7 @@ public final actor SyncEngine { } public func fetchChanges() async throws { - try await underlyingSyncEngine.fetchChanges(.init(scope: .all, operationGroup: nil)) + try await underlyingSyncEngine.fetchChanges() } public func deleteLocalData() throws { @@ -1228,8 +1228,8 @@ extension Logger { .joined(separator: ", ") let failedZoneDeletes = event.failedZoneDeletes.isEmpty - ? "⚪️ No failed saved zones" - : "🛑 Failed zone saves (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" + ? "⚪️ No failed deleted zones" + : "🛑 Failed zone delete (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" debug( """ @@ -1248,7 +1248,7 @@ extension Logger { let savedRecords = savedRecordsByZoneName.keys .sorted() .map { "\($0) (\(savedRecordsByZoneName[$0]!.count))" } - .joined(separator: ",") + .joined(separator: ", ") let deletedRecordsByZoneName = Dictionary( grouping: event.deletedRecordIDs, @@ -1257,7 +1257,7 @@ extension Logger { let deletedRecords = deletedRecordsByZoneName.keys .sorted() .map { "\($0) (\(deletedRecordsByZoneName[$0]!.count))" } - .joined(separator: ",") + .joined(separator: ", ") let failedRecordSavesByZoneName = Dictionary( grouping: event.failedRecordSaves, From 26478fb1b3eff8ccbed0d321a1f855c5e7bbc5b0 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 19 May 2025 14:29:47 -0700 Subject: [PATCH 052/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 31 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 65 +- Sources/SharingGRDBCore/CloudKitOld.swift | 953 ------------------ 3 files changed, 63 insertions(+), 986 deletions(-) delete mode 100644 Sources/SharingGRDBCore/CloudKitOld.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index ceb4696d..385ce779 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -35,6 +35,37 @@ extension CKRecord? { typealias DataRepresentation = CKRecord.DataRepresentation? } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKRecord { + static func `for`(_ row: T) -> CKRecord? { + @Dependency(\.defaultSyncEngine) var defaultSyncEngine + guard let metadatabase = try? DatabasePool(container: defaultSyncEngine.container) + else { return nil } + let record = + withErrorReporting { + try metadatabase.read { db in + try Metadata + .where { + $0.zoneName.eq(T.tableName) + && $0.recordName.eq( + SQLQueryExpression( + T.TableColumns.PrimaryKey( + queryOutput: row[keyPath: T.columns.primaryKey.keyPath] + ) + .queryFragment + ) + ) + } + .select(\.lastKnownServerRecord) + .fetchOne(db) + } + } + ?? nil + guard let record else { return nil } + return record + } +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { func update(with row: T, userModificationDate: Date?) { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 65a51cd4..67a2c332 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -44,7 +44,7 @@ public final actor SyncEngine { func setUpSyncEngine() throws { defer { underlyingSyncEngine = defaultSyncEngine } - metadatabase = try defaultMetadatabase + metadatabase = try DatabasePool(container: container) var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true @@ -124,6 +124,7 @@ public final actor SyncEngine { } } try database.write { db in + let metadatabaseURL: URL = .metadatabase(container: container) try SQLQueryExpression( "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" ) @@ -451,7 +452,7 @@ public final actor SyncEngine { ) .execute(db) } - try FileManager.default.removeItem(at: metadatabaseURL) + try FileManager.default.removeItem(at: .metadatabase(container: container)) } public func fetchChanges() async throws { @@ -501,35 +502,6 @@ public final actor SyncEngine { ) } - private var metadatabaseURL: URL { - URL.metadatabase(container: container) - } - - private var defaultMetadatabase: any DatabaseWriter { - get throws { - var configuration = Configuration() - configuration.prepareDatabase { db in - db.trace { - logger.trace("\($0.expandedDescription)") - } - } - logger.debug( - """ - SharingGRDB: Metadatabase connection: - open "\(self.metadatabaseURL.path(percentEncoded: false))" - """ - ) - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - return try DatabaseQueue( - path: metadatabaseURL.path(percentEncoded: false), - configuration: configuration - ) - } - } - private var defaultSyncEngine: CKSyncEngine { CKSyncEngine( CKSyncEngine.Configuration( @@ -891,7 +863,7 @@ extension SyncEngine: CKSyncEngineDelegate { } private func refreshLastKnownServerRecord(_ record: CKRecord) { - let localRecord = metadataFor(recordID: record.recordID) + let metadata = metadataFor(recordID: record.recordID) func updateLastKnownServerRecord() { withErrorReporting(.sharingGRDBCloudKitFailure) { @@ -904,7 +876,7 @@ extension SyncEngine: CKSyncEngineDelegate { } } - if let lastKnownDate = localRecord?.lastKnownServerRecord?.modificationDate { + if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { if let recordDate = record.modificationDate, lastKnownDate < recordDate { updateLastKnownServerRecord() } @@ -1102,6 +1074,33 @@ extension String { fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" } +@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) +extension DatabaseWriter where Self == DatabasePool { + init(container: CKContainer) throws { + let path = URL.metadatabase(container: container).path(percentEncoded: false) + var configuration = Configuration() + configuration.prepareDatabase { db in + db.trace { + logger.debug("\($0.expandedDescription)") + } + } + logger.debug( + """ + SharingGRDB: Metadatabase connection: + open "\(path)" + """ + ) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + try self.init( + path: path, + configuration: configuration + ) + } +} + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) extension URL { fileprivate static func metadatabase(container: CKContainer) -> Self { diff --git a/Sources/SharingGRDBCore/CloudKitOld.swift b/Sources/SharingGRDBCore/CloudKitOld.swift deleted file mode 100644 index 7ce2d28f..00000000 --- a/Sources/SharingGRDBCore/CloudKitOld.swift +++ /dev/null @@ -1,953 +0,0 @@ -//#if canImport(CloudKit) -// import CloudKit -// import Dependencies -// import OSLog -// -// extension DependencyValues { -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// public var cloudKitDatabase: CloudKitDatabase { -// get { self[CloudKitDatabase.self] } -// set { self[CloudKitDatabase.self] = newValue } -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// extension CloudKitDatabase: TestDependencyKey { -// public static var testValue: CloudKitDatabase { -// if shouldReportUnimplemented { -// reportIssue("TODO") -// } -// return try! CloudKitDatabase( -// container: CKContainer(identifier: "default"), -// database: try! DatabaseQueue(), -// tables: [] -// ) -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// public actor CloudKitDatabase { -// let container: CKContainer -// let database: any DatabaseWriter -// var syncEngine: CKSyncEngine! -// var stateSerialization: CKSyncEngine.State.Serialization? -// let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] -// var delegate: Delegate -// -// public init( -// container: CKContainer, -// database: any DatabaseWriter, -// tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] -// ) throws { -// self.container = container -// self.database = database -// self.delegate = Delegate(container: container) -// self.tables = tables -// let stateSerializationData = -// UserDefaults.standard.data( -// forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) -// ) ?? Data() -// stateSerialization = try? JSONDecoder().decode( -// CKSyncEngine.State.Serialization.self, -// from: stateSerializationData -// ) -// let configuration = CKSyncEngine.Configuration( -// database: container.privateCloudDatabase, -// stateSerialization: stateSerialization, -// delegate: delegate -// ) -// let syncEngine = CKSyncEngine(configuration) -// self.syncEngine = syncEngine -// delegate.syncEngine = syncEngine -// try? FileManager.default -// .createDirectory( -// at: URL.applicationSupportDirectory, -// withIntermediateDirectories: false -// ) -// let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") -// logger.info("open \(url.absoluteString)") -// let cloudKitDatabase = try DatabasePool(path: url.absoluteString) -// var migrator = DatabaseMigrator() -// migrator.registerMigration("Create SharingGRDB tables") { db in -// try SQLQueryExpression( -// """ -// CREATE TABLE "sharing_grdb_cloudkit" ( -// "tableName" TEXT NOT NULL, -// "primaryKey" TEXT NOT NULL, -// "recordData" BLOB, -// "userModificationDate" TEXT, -// PRIMARY KEY("tableName", "primaryKey") -// ) -// """ -// ) -// .execute(db) -// } -// try migrator.migrate(cloudKitDatabase) -// try database.write { db in -// try db.execute( -// literal: """ -// ATTACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" -// """ -// ) -// try createTriggers(db: db, cloudKitDatabase: self) -// } -// Self.saveZones(syncEngine: syncEngine, tables: tables) -// } -// -// deinit { -// print("?!?!?!") -// } -// -// func tearDownSyncEngine() throws { -// let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") -// try database.write { db in -// try dropTriggers(db: db, tables: tables) -// try db.execute( -// literal: """ -// DETACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" -// """ -// ) -// } -// try? FileManager.default.removeItem(at: url) -// } -// -// func restartSyncEngine() throws { -// try tearDownSyncEngine() -// // setUpSyncEngine() -// -// // delete triggers -// // delete all data from tables -// // detach metadata database -// // delete metadata database -// // everything in initializer -// -// UserDefaults.standard.removeObject( -// forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) -// ) -// stateSerialization = nil -// self.delegate = Delegate(container: container) -// let configuration = CKSyncEngine.Configuration( -// database: container.privateCloudDatabase, -// stateSerialization: stateSerialization, -// delegate: delegate -// ) -// syncEngine = CKSyncEngine(configuration) -// delegate.syncEngine = syncEngine -// saveZones() -// } -// -// static func saveZones( -// syncEngine: CKSyncEngine, -// tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] -// ) { -// syncEngine.state.add( -// pendingDatabaseChanges: tables.map { -// .saveZone(CKRecordZone(zoneName: $0.tableName)) -// } -// ) -// } -// -// func saveZones() { -// Self.saveZones(syncEngine: syncEngine, tables: tables) -// } -// -// func didInsert(tableName: String, id: String) { -// syncEngine.state.add( -// pendingRecordZoneChanges: [ -// .saveRecord( -// CKRecord.ID( -// recordName: id, -// zoneID: CKRecordZone(zoneName: tableName).zoneID -// ) -// ) -// ] -// ) -// } -// -// func didUpdate(tableName: String, id: String) { -// // TODO: perform modification date checks -// syncEngine.state.add( -// pendingRecordZoneChanges: [ -// .saveRecord( -// CKRecord.ID( -// recordName: id, -// zoneID: CKRecordZone(zoneName: tableName).zoneID -// ) -// ) -// ] -// ) -// } -// -// func willDelete(tableName: String, id: String) { -// syncEngine.state.add( -// pendingRecordZoneChanges: [ -// .deleteRecord( -// CKRecord.ID( -// recordName: id, -// zoneID: CKRecordZone(zoneName: tableName).zoneID -// ) -// ) -// ] -// ) -// } -// -// #if DEBUG -// public func deleteAllRecords() async throws { -// syncEngine.state.add( -// pendingDatabaseChanges: tables.map { table in -// .deleteZone(CKRecordZone.ID(zoneName: table.tableName)) -// } -// ) -// try await syncEngine.sendChanges() -// } -// #endif -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { -// @Dependency(\.defaultDatabase) var database -// let container: CKContainer -// var syncEngine: CKSyncEngine! -// init(container: CKContainer) { -// self.container = container -// } -// -// func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { -// logger.info("CloudKitDatabase.Delegate.handleEvent.\(event)") -// switch event { -// case .stateUpdate(let stateUpdate): -// withErrorReporting { -// UserDefaults.standard.set( -// try JSONEncoder().encode(stateUpdate.stateSerialization), -// forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) -// ) -// } -// break -// case .accountChange(_): -// // TODO -// break -// case .fetchedDatabaseChanges(let changes): -// handleFetchedDatabaseChanges(changes) -// break -// case .fetchedRecordZoneChanges(let changes): -// handleFetchedRecordZoneChanges(changes) -// break -// case .sentDatabaseChanges(_): -// // TODO -// break -// case .sentRecordZoneChanges(let changes): -// handleSentRecordZoneChanges(changes) -// break -// case .willFetchChanges(_): -// // TODO -// break -// case .willFetchRecordZoneChanges(_): -// // TODO -// break -// case .didFetchRecordZoneChanges(_): -// // TODO -// break -// case .didFetchChanges(_): -// // TODO -// break -// case .willSendChanges(_): -// // TODO -// break -// case .didSendChanges(_): -// // TODO -// break -// @unknown default: -// // TODO -// break -// } -// } -// -// private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) { -// var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() -// var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() -// defer { -// syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) -// syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) -// } -// -// withErrorReporting { -// try database.write { db in -// for savedRecord in changes.savedRecords { -// try db.cacheNewRecordIfNewer(savedRecord) -// } -// -// for failedRecordSave in changes.failedRecordSaves { -// // TODO: do this -// switch failedRecordSave.error.code { -// // case .internalError: -// // <#code#> -// // case .partialFailure: -// // <#code#> -// // case .networkUnavailable: -// // <#code#> -// // case .networkFailure: -// // <#code#> -// // case .badContainer: -// // <#code#> -// // case .serviceUnavailable: -// // <#code#> -// // case .requestRateLimited: -// // <#code#> -// // case .missingEntitlement: -// // <#code#> -// // case .notAuthenticated: -// // <#code#> -// // case .permissionFailure: -// // <#code#> -// case .unknownItem: -// print("") -// // case .invalidArguments: -// // <#code#> -// // case .resultsTruncated: -// // <#code#> -// case .serverRecordChanged: -// guard let serverRecord = failedRecordSave.error.serverRecord -// else { continue } -// try db.cacheNewRecordIfNewer(serverRecord) -// try serverRecord.upsertIfNewer(db: db) -// print( -// serverRecord.recordID, -// failedRecordSave.record.recordID, -// serverRecord.recordID == failedRecordSave.record.recordID -// ) -// newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) -// // case .serverRejectedRequest: -// // <#code#> -// // case .assetFileNotFound: -// // <#code#> -// // case .assetFileModified: -// // <#code#> -// // case .incompatibleVersion: -// // <#code#> -// // case .constraintViolation: -// // <#code#> -// // case .operationCancelled: -// // <#code#> -// // case .changeTokenExpired: -// // <#code#> -// // case .batchRequestFailed: -// // <#code#> -// // case .zoneBusy: -// // <#code#> -// // case .badDatabase: -// // <#code#> -// // case .quotaExceeded: -// // <#code#> -// case .zoneNotFound: -// // TODO: recreate zone if it matches a table name? -// let zone = CKRecordZone(zoneID: failedRecordSave.record.recordID.zoneID) -// newPendingDatabaseChanges.append(.saveZone(zone)) -// newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) -// -// // case .limitExceeded: -// // <#code#> -// // case .userDeletedZone: -// // <#code#> -// // case .tooManyParticipants: -// // <#code#> -// // case .alreadyShared: -// // <#code#> -// // case .referenceViolation: -// // <#code#> -// // case .managedAccountRestricted: -// // <#code#> -// // case .participantMayNeedVerification: -// // <#code#> -// // case .serverResponseLost: -// // <#code#> -// // case .assetNotAvailable: -// // <#code#> -// // case .accountTemporarilyUnavailable: -// // <#code#> -// -// case .networkFailure, -// .networkUnavailable, -// .zoneBusy, -// .serviceUnavailable, -// .notAuthenticated, -// .operationCancelled: -// print("") -// default: -// reportIssue("Unhandled error: \(failedRecordSave.error.code)") -// } -// } -// -// for (recordID, failedRecordDelete) in changes.failedRecordDeletes { -// // TODO: do this -// print(failedRecordDelete) -// } -// -// // TODO: double check this is correct. the sample code doesn't have this -// for deletedRecordID in changes.deletedRecordIDs { -// try deletedRecordID.delete(db: db) -// } -// } -// } -// } -// -// private func handleFetchedRecordZoneChanges( -// _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges -// ) { -// withErrorReporting { -// try database.write { db in -// for modification in changes.modifications { -// try modification.record.upsertIfNewer(db: db) -// try db.cacheNewRecordIfNewer(modification.record) -// } -// -// for deletion in changes.deletions { -// try deletion.recordID.delete(db: db) -// } -// } -// } -// } -// -// private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) -// { -// withErrorReporting { -// try database.write { db in -// for deletion in changes.deletions { -// let tableName = deletion.zoneID.zoneName -// try SQLQueryExpression( -// """ -// DELETE FROM "\(raw: tableName)" -// """ -// ) -// .execute(db) -// -// syncEngine.state.add( -// pendingDatabaseChanges: [ -// .saveZone(CKRecordZone(zoneName: tableName)) -// ] -// ) -// } -// } -// } -// } -// -// func nextRecordZoneChangeBatch( -// _ context: CKSyncEngine.SendChangesContext, -// syncEngine: CKSyncEngine -// ) async -> CKSyncEngine.RecordZoneChangeBatch? { -// logger.info("CloudKitDatabase.Delegate.nextRecordZoneChangeBatch \(context)") -// -// let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) -// let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in -// do { -// return try database.write { db in -// let record = try db.fetchLastCachedRecord(id: recordID) -// let row = try Row.fetchOne( -// db, -// SQLRequest( -// sql: """ -// SELECT * FROM "\(recordID.tableName)" WHERE "id" = ? -// """, -// arguments: [recordID.primaryKey] -// ) -// ) -// -// guard let row -// else { -// syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) -// return nil -// } -// record.update(with: row) -// try db.cacheNewRecordIfNewer(record) -// return record -// } -// } catch { -// reportIssue(error) -// return nil -// } -// } -// return batch -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// extension CKRecord { -// func update(with row: Row) { -// for columnName in row.columnNames { -// switch row[columnName]?.databaseValue.storage { -// case .null: -// if encryptedValues[columnName] != nil { -// encryptedValues[columnName] = nil -// } -// case .int64(let value): -// if object(forKey: columnName) as? Int64 != value { -// encryptedValues[columnName] = value -// } -// case .double(let value): -// if object(forKey: columnName) as? Double != value { -// encryptedValues[columnName] = value -// } -// case .string(let value): -// if object(forKey: columnName) as? String != value { -// encryptedValues[columnName] = value -// } -// case .blob(let value): -// if object(forKey: columnName) as? Data != value { -// encryptedValues[columnName] = value -// } -// case .none: -// break -// } -// } -// } -// } -// -// extension CKRecord.ID { -// fileprivate var primaryKey: String { recordName } -// fileprivate var tableName: String { zoneID.zoneName } -// } -// -// private func stateSerializationKey(containerIdentifier: String?) -> String { -// (containerIdentifier ?? "") + ".stateSerializationData" -// } -// -// extension Database { -// func cacheNewRecordIfNewer(_ newRecord: CKRecord) throws { -// let existingRecord = try fetchLastCachedRecord(id: newRecord.recordID) -// if let existingRecordModificationDate = existingRecord.modificationDate { -// if let newRecordModificationDate = newRecord.modificationDate, -// existingRecordModificationDate < newRecordModificationDate -// { -// try update() -// } else { -// print("Modification date caught") -// } -// } else { -// try update() -// } -// -// func update() throws { -// let archiver = NSKeyedArchiver(requiringSecureCoding: true) -// newRecord.encodeSystemFields(with: archiver) -// // TODO: should we use userModificationDate based on record.modificationDate? -// try SQLQueryExpression( -// """ -// INSERT INTO "sharing_grdb_cloudkit" -// ("tableName", "primaryKey", "recordData", "userModificationDate") -// VALUES ( -// \(bind: newRecord.recordID.tableName), -// \(bind: newRecord.recordID.primaryKey), -// \(archiver.encodedData), -// \(bind: Date.ISO8601Representation(queryOutput: .distantPast)) -// ) -// ON CONFLICT("tableName", "primaryKey") DO UPDATE SET -// "recordData" = \(archiver.encodedData) -// """ -// ) -// .execute(self) -// } -// } -// -// func fetchLastCachedRecord(id recordID: CKRecord.ID) throws -> CKRecord { -// return try SQLQueryExpression( -// """ -// SELECT "recordData" -// FROM "sharing_grdb_cloudkit" -// WHERE "tableName" = \(bind: recordID.tableName) -// AND "primaryKey" = \(bind: recordID.primaryKey) -// """, -// as: Data?.self -// ) -// .fetchOne(self) -// .flatMap { $0 } -// .flatMap { data in -// let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) -// unarchiver.requiresSecureCoding = true -// return CKRecord(coder: unarchiver) -// } -// ?? CKRecord(recordType: recordID.tableName, recordID: recordID) -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// extension CKRecord { -// func upsertIfNewer(db: Database) throws { -// let userModificationDate = -// try SQLQueryExpression( -// """ -// SELECT "userModificationDate" FROM "sharing_grdb_cloudkit" -// WHERE "tableName" = \(bind: recordID.tableName) -// AND "primaryKey" = \(bind: recordID.primaryKey) -// """, -// as: Date?.ISO8601Representation.self -// ) -// .fetchOne(db) -// ?? nil -// -// if let userModificationDate, -// userModificationDate > (modificationDate ?? .distantPast) -// { -// print("Modification date caught") -// } else { -// // TODO: can we use record.keysChanged to update only columns that changed? -// let columnNames = try String.fetchAll( -// db, -// sql: """ -// SELECT "name" -// FROM pragma_table_info('\(recordID.tableName)') -// """ -// ) -// var query: QueryFragment = """ -// INSERT INTO "\(raw: recordID.tableName)" ( -// """ -// query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) -// query.append( -// """ -// ) VALUES ( -// """ -// ) -// query.append( -// columnNames.map { columnName in -// "\(bind: convert(encryptedValues[columnName]))" -// }.joined(separator: ",") -// ) -// query.append( -// """ -// ) ON CONFLICT("id") DO UPDATE SET -// """ -// ) -// query.append( -// columnNames -// .map { " \(quote: $0) = excluded.\(quote: $0)" } -// .joined(separator: ",") -// ) -// try SQLQueryExpression(query).execute(db) -// } -// } -// } -// -// extension CKRecord.ID { -// func delete(db: Database) throws { -// try SQLQueryExpression( -// """ -// DELETE FROM "\(raw: tableName)" -// WHERE "id" = \(bind: primaryKey) -// """ -// ) -// .execute(db) -// } -// } -// -// extension CKRecordZone.ID { -// func deleteAll(db: Database) throws { -// try SQLQueryExpression( -// """ -// DELETE FROM "\(raw: zoneName)" -// """ -// ) -// .execute(db) -// } -// } -// -// private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression { -// guard let value else { -// // TODO: better way? -// return SQLQueryExpression("NULL", as: Void?.self) -// } -// if let value = value as? Int64 { -// return value -// } else if let value = value as? Double { -// return value -// } else if let value = value as? String { -// return value -// } else if let value = value as? Data { -// return value -// } else { -// fatalError("TODO: do we need to do all numeric types?") -// } -// } -// -// extension DatabaseFunction { -// fileprivate convenience init( -// name: String, -// function: @escaping @Sendable (String, String) async -> Void -// ) { -// self.init(name, argumentCount: 2) { arguments in -// guard -// let tableName = String.fromDatabaseValue(arguments[0]), -// let id = String.fromDatabaseValue(arguments[1]) -// else { -// return 0 -// } -// Task { await function(tableName, id) } -// return 0 -// } -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// func dropTriggers( -// db: Database, -// tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] -// ) throws { -// db.remove(function: .didInsert) -// db.remove(function: .didUpdate) -// db.remove(function: .willDelete) -// for table in tables { -// try SQLQueryExpression( -// """ -// DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" -// """ -// ) -// .execute(db) -// let foreignKeys = try SQLQueryExpression( -// """ -// SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) -// """, -// as: PragmaForeignKey.self -// ) -// .fetchAll(db) -// for foreignKey in foreignKeys { -// switch foreignKey.onDelete { -// case .cascade: -// try SQLQueryExpression( -// """ -// DROP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" -// """ -// ) -// .execute(db) -// case .restrict: -// fatalError("TODO: report issue?") -// case .setDefault: -// fatalError("TODO: report issue?") -// case .setNull: -// try SQLQueryExpression( -// """ -// DROP TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" -// """ -// ) -// .execute(db) -// case .noAction: -// continue -// } -// -// switch foreignKey.onUpdate { -// case .cascade: -// fatalError("TODO") -// case .restrict: -// fatalError("TODO") -// case .setDefault: -// fatalError("TODO") -// case .setNull: -// fatalError("TODO") -// case .noAction: -// continue -// } -// } -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// func createTriggers( -// db: Database, -// cloudKitDatabase: CloudKitDatabase -// ) throws { -// db.add(function: .didInsert) -// db.add(function: .didUpdate) -// db.add(function: .willDelete) -// for table in cloudKitDatabase.tables { -// try Trigger.delete(tableName: table.tableName).sql -// .execute(db) -// try Trigger.insert(tableName: table.tableName).sql -// .execute(db) -// try Trigger.update(tableName: table.tableName).sql -// .execute(db) -// try SQLQueryExpression( -// """ -// CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" -// AFTER UPDATE ON \(table) FOR EACH ROW BEGIN -// INSERT INTO "sharing_grdb_cloudkit" -// ("tableName", "primaryKey", "userModificationDate") -// VALUES -// ( -// '\(raw: table.tableName)', -// new."id", -// datetime('subsec') -// ) -// ON CONFLICT("tableName", "primaryKey") DO UPDATE SET -// "userModificationDate" = excluded."userModificationDate"; -// END -// """ -// ) -// .execute(db) -// let foreignKeys = try SQLQueryExpression( -// """ -// SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) -// """, -// as: PragmaForeignKey.self -// ) -// .fetchAll(db) -// for foreignKey in foreignKeys { -// switch foreignKey.onDelete { -// case .cascade: -// try SQLQueryExpression( -// """ -// CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" -// AFTER DELETE ON \(quote: foreignKey.table) -// FOR EACH ROW BEGIN -// DELETE FROM \(quote: table.tableName) -// WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); -// END -// """ -// ) -// .execute(db) -// case .restrict: -// fatalError("TODO: report issue?") -// case .setDefault: -// fatalError("TODO: report issue?") -// case .setNull: -// try SQLQueryExpression( -// """ -// CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" -// AFTER DELETE ON \(quote: foreignKey.table) -// FOR EACH ROW BEGIN -// UPDATE \(quote: table.tableName) -// SET \(quote: foreignKey.from) = NULL -// WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); -// END -// """ -// ) -// .execute(db) -// case .noAction: -// continue -// } -// -// switch foreignKey.onUpdate { -// case .cascade: -// fatalError("TODO") -// case .restrict: -// fatalError("TODO") -// case .setDefault: -// fatalError("TODO") -// case .setNull: -// fatalError("TODO") -// case .noAction: -// continue -// } -// } -// } -// } -// -// private struct PragmaForeignKey: QueryDecodable, QueryRepresentable { -// enum Action: String, QueryBindable { -// case cascade = "CASCADE" -// case restrict = "RESTRICT" -// case setDefault = "SET DEFAULT" -// case setNull = "SET NULL" -// case noAction = "NO ACTION" -// } -// -// typealias QueryValue = Self -// -// let table: String -// let from: String -// let to: String -// let onUpdate: Action -// let onDelete: Action -// -// init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { -// guard -// let table = try decoder.decode(String.self), -// let from = try decoder.decode(String.self), -// let to = try decoder.decode(String.self), -// let onUpdate = try decoder.decode(Action.self), -// let onDelete = try decoder.decode(Action.self) -// else { -// throw QueryDecodingError.missingRequiredColumn -// } -// self.table = table -// self.from = from -// self.to = to -// self.onUpdate = onUpdate -// self.onDelete = onDelete -// } -// -// static var columns: QueryFragment { -// """ -// "table", "from", "to", "on_update", "on_delete", "match" -// """ -// } -// } -// -// private struct Trigger { -// let idColumn: String -// let function: String -// let tableName: String -// let type: String -// let when: String -// static func delete(tableName: String) -> Self { -// Trigger( -// idColumn: "old.id", -// function: "willDelete", -// tableName: tableName, -// type: "DELETE", -// when: "BEFORE" -// ) -// } -// static func insert(tableName: String) -> Self { -// Trigger( -// idColumn: "new.id", -// function: "didInsert", -// tableName: tableName, -// type: "INSERT", -// when: "AFTER" -// ) -// } -// static func update(tableName: String) -> Self { -// Trigger( -// idColumn: "new.id", -// function: "didUpdate", -// tableName: tableName, -// type: "UPDATE", -// when: "AFTER" -// ) -// } -// var sql: SQLQueryExpression { -// SQLQueryExpression( -// """ -// CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" -// \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN -// SELECT \(raw: function)('\(raw: tableName)', \(raw: idColumn)); -// END -// """ -// ) -// } -// } -// -// @available(macOS 11, iOS 14, watchOS 7, tvOS 14, *) -// private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") -//#endif -// -//@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -//extension DatabaseFunction { -// fileprivate static var didInsert: Self { -// @Dependency(\.cloudKitDatabase) var cloudKitDatabase -// return Self( -// name: "didInsert", -// function: { await cloudKitDatabase.didInsert(tableName: $0, id: $1) } -// ) -// } -// fileprivate static var didUpdate: Self { -// @Dependency(\.cloudKitDatabase) var cloudKitDatabase -// return Self( -// name: "didUpdate", -// function: { await cloudKitDatabase.didUpdate(tableName: $0, id: $1) } -// ) -// } -// fileprivate static var willDelete: Self { -// @Dependency(\.cloudKitDatabase) var cloudKitDatabase -// return Self( -// name: "willDelete", -// function: { await cloudKitDatabase.willDelete(tableName: $0, id: $1) } -// ) -// } -//} From 715abf5be58f68864b1946b9e36d6aef114a7ea9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 19 May 2025 15:00:51 -0700 Subject: [PATCH 053/581] wip --- Examples/Reminders/RemindersApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index a0bfa9f8..e54d8f4d 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -14,8 +14,8 @@ struct RemindersApp: App { container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), database: $0.defaultDatabase, tables: [ - Reminder.self, RemindersList.self, + Reminder.self, Tag.self, ReminderTag.self, ] From 7295cc9ee1ff9344bbb145a8fc50c098974bcfbd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 19 May 2025 16:51:00 -0700 Subject: [PATCH 054/581] wip --- .../SharingGRDBCore/CloudKit/CKRecord.swift | 14 +- .../SharingGRDBCore/CloudKit/Metadata.swift | 10 +- .../CloudKit/StateSerialization.swift | 6 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 133 +++-- Sources/SharingGRDBCore/CloudKit/Zone.swift | 6 +- Tests/SharingGRDB.xctestplan | 31 ++ Tests/SharingGRDBTests/CloudKitTests.swift | 492 +++++++++--------- .../Internal/MockCloudKit.swift | 98 ++++ 8 files changed, 459 insertions(+), 331 deletions(-) create mode 100644 Tests/SharingGRDB.xctestplan create mode 100644 Tests/SharingGRDBTests/Internal/MockCloudKit.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift index 0e9d3237..b60d14bd 100644 --- a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift +++ b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift @@ -2,20 +2,20 @@ import CloudKit import StructuredQueriesCore extension CKRecord { - struct DataRepresentation: QueryBindable, QueryRepresentable { - let queryOutput: CKRecord + package struct DataRepresentation: QueryBindable, QueryRepresentable { + package let queryOutput: CKRecord - var queryBinding: QueryBinding { + package var queryBinding: QueryBinding { let archiver = NSKeyedArchiver(requiringSecureCoding: true) queryOutput.encodeSystemFields(with: archiver) return archiver.encodedData.queryBinding } - init(queryOutput: CKRecord) { + package init(queryOutput: CKRecord) { self.queryOutput = queryOutput } - init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { guard let data = try Data?(decoder: &decoder) else { throw QueryDecodingError.missingRequiredColumn } @@ -32,7 +32,7 @@ extension CKRecord { } extension CKRecord? { - typealias DataRepresentation = CKRecord.DataRepresentation? + package typealias DataRepresentation = CKRecord.DataRepresentation? } @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) @@ -66,7 +66,7 @@ extension CKRecord { } } - var userModificationDate: Date? { + package var userModificationDate: Date? { get { encryptedValues[Self.userModificationDateKey] as? Date } set { encryptedValues[Self.userModificationDateKey] = newValue } } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 9d01117c..737e49bd 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -2,12 +2,12 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) // @Table("sharing_grdb_cloudkit_metadata") -struct Metadata { - var zoneName: String - var recordName: String +package struct Metadata { + package var zoneName: String + package var recordName: String // @Column(as: CKRecord?.DataRepresentation.self) - var lastKnownServerRecord: CKRecord? - var userModificationDate: Date? + package var lastKnownServerRecord: CKRecord? + package var userModificationDate: Date? } // NB: This is generated by inlining the above macro applications. diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift index 6d788f5b..37906bce 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift @@ -2,10 +2,10 @@ import CloudKit // @Table("sharing_grdb_cloudkit_stateSerialization") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -struct StateSerialization { - var id = 1 +package struct StateSerialization { + package var id = 1 // @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) - var data: CKSyncEngine.State.Serialization + package var data: CKSyncEngine.State.Serialization } // NB: This is generated by inlining the above macro applications. diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 1ae5d768..3b6dc71b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -14,9 +14,10 @@ extension DependencyValues { public final actor SyncEngine { nonisolated let database: any DatabaseWriter lazy var metadatabase: any DatabaseWriter = try! DatabaseQueue() - private var metadatabaseURL: URL? - nonisolated let tables: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] - var underlyingSyncEngine: (any CKSyncEngineProtocol)! // = defaultSyncEngine + private let metadatabaseURL: URL + nonisolated let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + nonisolated let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + var underlyingSyncEngine: (any CKSyncEngineProtocol)! let defaultSyncEngine: (SyncEngine) -> any CKSyncEngineProtocol public init( @@ -24,6 +25,7 @@ public final actor SyncEngine { database: any DatabaseWriter, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { + fatalError() self.init( defaultSyncEngine: { syncEngine in CKSyncEngine( @@ -45,12 +47,13 @@ public final actor SyncEngine { package init( defaultSyncEngine: any CKSyncEngineProtocol, database: any DatabaseWriter, + metadatabaseURL: URL, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { self.init( defaultSyncEngine: { _ in defaultSyncEngine }, database: database, - metadatabaseURL: nil, + metadatabaseURL: metadatabaseURL, tables: tables ) } @@ -58,7 +61,7 @@ public final actor SyncEngine { private init( defaultSyncEngine: @escaping (SyncEngine) -> any CKSyncEngineProtocol, database: any DatabaseWriter, - metadatabaseURL: URL?, + metadatabaseURL: URL, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { // TODO: Explain why / link to documentation? @@ -71,7 +74,8 @@ public final actor SyncEngine { self.defaultSyncEngine = defaultSyncEngine self.database = database self.metadatabaseURL = metadatabaseURL - self.tables = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) + self.tables = tables + self.tablesByName = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) Task { await withErrorReporting(.sharingGRDBCloudKitFailure) { try await setUpSyncEngine() @@ -130,7 +134,7 @@ public final actor SyncEngine { SELECT "name", "sql" FROM "sqlite_master" WHERE "type" = 'table' - AND "name" IN (\(tables.keys.map(\.queryFragment).joined(separator: ", "))) + AND "name" IN (\(tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) """, as: Zone.self ) @@ -162,32 +166,25 @@ public final actor SyncEngine { } } try database.write { db in - if let metadatabaseURL { - try SQLQueryExpression( - "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" - ) - .execute(db) - } else { - try SQLQueryExpression( - "ATTACH DATABASE 'file:metadatabase?mode=memory&cache=shared' AS \(quote: .sharingGRDBCloudKitSchemaName)" - ) - .execute(db) - } + try SQLQueryExpression( + "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" + ) + .execute(db) db.add(function: .areTriggersEnabled) - db.add(function: .didUpdate) - db.add(function: .willDelete) - for table in tables.values { + db.add(function: .didUpdate(syncEngine: self)) + db.add(function: .willDelete(syncEngine: self)) + for table in tables { func open(_: T.Type) throws { try SQLQueryExpression( - Trigger(on: T.self, .after, .insert, select: .didUpdate).create + Trigger(on: T.self, .after, .insert, select: .didUpdate(syncEngine: self)).create ) .execute(db) try SQLQueryExpression( - Trigger(on: T.self, .after, .update, select: .didUpdate).create + Trigger(on: T.self, .after, .update, select: .didUpdate(syncEngine: self)).create ) .execute(db) try SQLQueryExpression( - Trigger(on: T.self, .before, .delete, select: .willDelete).create + Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).create ) .execute(db) try SQLQueryExpression( @@ -350,7 +347,7 @@ public final actor SyncEngine { package func tearDownSyncEngine() throws { try database.write { db in - for table in tables.values { + for table in tables { func open(_: T.Type) throws { let foreignKeys = try SQLQueryExpression( """ @@ -431,33 +428,31 @@ public final actor SyncEngine { ) .execute(db) try SQLQueryExpression( - Trigger(on: T.self, .before, .delete, select: .willDelete).drop + Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).drop ) .execute(db) try SQLQueryExpression( - Trigger(on: T.self, .after, .update, select: .didUpdate).drop + Trigger(on: T.self, .after, .update, select: .didUpdate(syncEngine: self)).drop ) .execute(db) try SQLQueryExpression( - Trigger(on: T.self, .after, .insert, select: .didUpdate).drop + Trigger(on: T.self, .after, .insert, select: .didUpdate(syncEngine: self)).drop ) .execute(db) } try open(table) } - db.remove(function: .willDelete) - db.remove(function: .didUpdate) + db.remove(function: .willDelete(syncEngine: self)) + db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .areTriggersEnabled) } - try database.read { db in + try database.writeWithoutTransaction { db in try SQLQueryExpression( "DETACH DATABASE \(quote: .sharingGRDBCloudKitSchemaName)" ) .execute(db) } - if let metadatabaseURL { - try FileManager.default.removeItem(at: metadatabaseURL) - } + try FileManager.default.removeItem(at: metadatabaseURL) } public func fetchChanges() async throws { @@ -467,7 +462,7 @@ public final actor SyncEngine { public func deleteLocalData() throws { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in - for table in tables.values { + for table in tables { func open(_: T.Type) { withErrorReporting(.sharingGRDBCloudKitFailure) { try T.delete().execute(db) @@ -515,24 +510,20 @@ public final actor SyncEngine { logger.trace("\($0.expandedDescription)") } } - if let metadatabaseURL { - logger.debug( + logger.debug( """ SharingGRDB: Metadatabase connection: - open "\(metadatabaseURL.path(percentEncoded: false))" + open "\(self.metadatabaseURL.path(percentEncoded: false))" """ - ) - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - return try DatabaseQueue( - path: metadatabaseURL.path(percentEncoded: false), - configuration: configuration - ) - } else { - return try DatabaseQueue(named: "metadatabase", configuration: configuration) - } + ) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + return try DatabaseQueue( + path: metadatabaseURL.path(percentEncoded: false), + configuration: configuration + ) } } } @@ -560,7 +551,10 @@ extension SyncEngine: CKSyncEngineDelegate { case .sentDatabaseChanges: break case .fetchedRecordZoneChanges(let event): - handleFetchedRecordZoneChanges(event) + handleFetchedRecordZoneChanges( + modifications: event.modifications.map(\.record), + deletions: event.deletions.map { ($0.recordID, $0.recordType) } + ) case .sentRecordZoneChanges(let event): handleSentRecordZoneChanges(event) case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, @@ -632,7 +626,7 @@ extension SyncEngine: CKSyncEngineDelegate { #endif let metadata = await metadataFor(recordID: recordID) - guard let table = tables[recordID.zoneID.zoneName] + guard let table = tablesByName[recordID.zoneID.zoneName] else { reportIssue("") syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) @@ -676,7 +670,7 @@ extension SyncEngine: CKSyncEngineDelegate { private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { switch event.changeType { case .signIn: - for table in tables.values { + for table in tables { underlyingSyncEngine.engineState.add( pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneName: table.tableName))] ) @@ -714,7 +708,7 @@ extension SyncEngine: CKSyncEngineDelegate { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in for deletion in event.deletions { - if let table = tables[deletion.zoneID.zoneName] { + if let table = tablesByName[deletion.zoneID.zoneName] { func open(_: T.Type) { withErrorReporting(.sharingGRDBCloudKitFailure) { try T.delete().execute(db) @@ -730,20 +724,21 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func handleFetchedRecordZoneChanges( - _ event: CKSyncEngine.Event.FetchedRecordZoneChanges + package func handleFetchedRecordZoneChanges( + modifications: [CKRecord], + deletions: [(CKRecord.ID, CKRecord.RecordType)] ) { - for modification in event.modifications { - mergeFromServerRecord(modification.record) - refreshLastKnownServerRecord(modification.record) + for modifiedRecord in modifications { + mergeFromServerRecord(modifiedRecord) + refreshLastKnownServerRecord(modifiedRecord) } - for deletion in event.deletions { - if let table = tables[deletion.recordID.zoneID.zoneName] { + for (recordID, _) in deletions { + if let table = tablesByName[recordID.zoneID.zoneName] { func open(_: T.Type) { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in - try T.find(recordID: deletion.recordID) + try T.find(recordID: recordID) .delete() .execute(db) } @@ -754,7 +749,7 @@ extension SyncEngine: CKSyncEngineDelegate { reportIssue( .sharingGRDBCloudKitFailure.appending( """ - : No table to delete from: "\(deletion.recordID.zoneID.zoneName)" + : No table to delete from: "\(recordID.zoneID.zoneName)" """ ) ) @@ -821,7 +816,7 @@ extension SyncEngine: CKSyncEngineDelegate { try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db) } ?? nil - guard let table = tables[record.recordID.zoneID.zoneName] + guard let table = tablesByName[record.recordID.zoneID.zoneName] else { reportIssue( .sharingGRDBCloudKitFailure.appending( @@ -928,16 +923,14 @@ extension SyncEngine: TestDependencyKey { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { - fileprivate static var didUpdate: Self { + fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { Self("didUpdate") { - @Dependency(\.defaultSyncEngine) var syncEngine await syncEngine.didUpdate(recordName: $0, zoneName: $1) } } - fileprivate static var willDelete: Self { - Self("willDelete") { - @Dependency(\.defaultSyncEngine) var syncEngine + fileprivate static func willDelete(syncEngine: SyncEngine) -> Self { + return Self("willDelete") { await syncEngine.willDelete(recordName: $0, zoneName: $1) } } @@ -1076,7 +1069,7 @@ private struct Unbindable: Error {} @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata { - static func find(recordID: CKRecord.ID) -> Where { + package static func find(recordID: CKRecord.ID) -> Where { Self.where { $0.zoneName.eq(recordID.zoneID.zoneName) && $0.recordName.eq(recordID.recordName) diff --git a/Sources/SharingGRDBCore/CloudKit/Zone.swift b/Sources/SharingGRDBCore/CloudKit/Zone.swift index 444995ab..e0e9f4cb 100644 --- a/Sources/SharingGRDBCore/CloudKit/Zone.swift +++ b/Sources/SharingGRDBCore/CloudKit/Zone.swift @@ -1,8 +1,8 @@ // @Table("sharing_grdb_cloudkit_zones") -struct Zone { +package struct Zone { // @Column(primaryKey: true) - let zoneName: String - let schema: String + package let zoneName: String + package let schema: String } // NB: This is generated by inlining the above macro applications. diff --git a/Tests/SharingGRDB.xctestplan b/Tests/SharingGRDB.xctestplan new file mode 100644 index 00000000..74b76fb1 --- /dev/null +++ b/Tests/SharingGRDB.xctestplan @@ -0,0 +1,31 @@ +{ + "configurations" : [ + { + "id" : "B0A22F73-0252-40F2-892F-A609B4DFBBCA", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : false + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "SharingGRDBTests", + "name" : "SharingGRDBTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "StructuredQueriesGRDBTests", + "name" : "StructuredQueriesGRDBTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift index 04e75d1e..e7694786 100644 --- a/Tests/SharingGRDBTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests.swift @@ -1,12 +1,13 @@ import CloudKit +import ConcurrencyExtras +import CustomDump import InlineSnapshotTesting import SharingGRDB import SnapshotTestingCustomDump -import ConcurrencyExtras import Testing @Suite(.snapshots(record: .failed)) -struct CloudKitTests { +final class CloudKitTests: Sendable { let database: any DatabaseWriter let _syncEngine: any Sendable let underlyingSyncEngine: MockSyncEngine @@ -19,247 +20,297 @@ struct CloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) init() async throws { - underlyingSyncState = MockSyncEngineState() - underlyingSyncEngine = MockSyncEngine(engineState: underlyingSyncState) - self.database = try SharingGRDBTests.database() + let database = try SharingGRDBTests.database() + let underlyingSyncState = MockSyncEngineState() + let underlyingSyncEngine = MockSyncEngine(engineState: underlyingSyncState) + self.database = database + self.underlyingSyncState = underlyingSyncState + self.underlyingSyncEngine = underlyingSyncEngine _syncEngine = SyncEngine( defaultSyncEngine: underlyingSyncEngine, database: database, + metadatabaseURL: URL.temporaryDirectory.appending( + path: "metadatabase.\(UUID().uuidString).sqlite" + ), tables: [Reminder.self, RemindersList.self] ) try await Task.sleep(for: .seconds(0.1)) } + deinit { + underlyingSyncState.assertPendingDatabaseChanges([]) + underlyingSyncState.assertPendingRecordZoneChanges([]) + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUpAndTearDown() async throws { - try await Task.sleep(for: .seconds(0.1)) - - try await database.read { db in - assertInlineSnapshot( - of: try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db), - as: .customDump - ) { - #""" - [ - [0]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_insert_remindersLists" - AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN - SELECT didUpdate( - "new"."id", - 'remindersLists' - ) - WHERE areTriggersEnabled(); - END - """, - [1]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_update_remindersLists" - AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - SELECT didUpdate( - "new"."id", - 'remindersLists' - ) - WHERE areTriggersEnabled(); - END - """, - [2]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_delete_remindersLists" - BEFORE DELETE ON "remindersLists" FOR EACH ROW BEGIN - SELECT willDelete( - "old"."id", - 'remindersLists' - ) - WHERE areTriggersEnabled(); - END - """, - [3]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataInserts" - AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName", "userModificationDate") - SELECT - 'remindersLists', - "new"."id", - datetime('subsec') - WHERE areTriggersEnabled() - ON CONFLICT("zoneName", "recordName") DO NOTHING; - END - """, - [4]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataUpdates" - AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName") - SELECT 'remindersLists', "new"."id" - WHERE areTriggersEnabled() - ON CONFLICT("zoneName", "recordName") DO UPDATE SET - "userModificationDate" = datetime('subsec'); - END - """, - [5]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_insert_reminders" - AFTER INSERT ON "reminders" FOR EACH ROW BEGIN - SELECT didUpdate( - "new"."id", - 'reminders' - ) - WHERE areTriggersEnabled(); - END - """, - [6]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_update_reminders" - AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - SELECT didUpdate( - "new"."id", - 'reminders' - ) - WHERE areTriggersEnabled(); - END - """, - [7]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_delete_reminders" - BEFORE DELETE ON "reminders" FOR EACH ROW BEGIN - SELECT willDelete( - "old"."id", - 'reminders' - ) - WHERE areTriggersEnabled(); - END - """, - [8]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataInserts" - AFTER INSERT ON "reminders" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName", "userModificationDate") - SELECT - 'reminders', - "new"."id", - datetime('subsec') - WHERE areTriggersEnabled() - ON CONFLICT("zoneName", "recordName") DO NOTHING; - END - """, - [9]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataUpdates" - AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName") - SELECT 'reminders', "new"."id" - WHERE areTriggersEnabled() - ON CONFLICT("zoneName", "recordName") DO UPDATE SET - "userModificationDate" = datetime('subsec'); - END - """, - [10]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_remindersLists_onDeleteCascade" - AFTER DELETE ON "remindersLists" - FOR EACH ROW BEGIN - DELETE FROM "reminders" - WHERE "remindersListID" = "old"."id"; - END - """ - ] - """# - } + var sqls = try await database.write { db in + try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) + } + assertInlineSnapshot(of: sqls, as: .customDump) { + #""" + [ + [0]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_insert_reminders" + AFTER INSERT ON "reminders" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'reminders' + ) + WHERE areTriggersEnabled(); + END + """, + [1]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_update_reminders" + AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'reminders' + ) + WHERE areTriggersEnabled(); + END + """, + [2]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_delete_reminders" + BEFORE DELETE ON "reminders" FOR EACH ROW BEGIN + SELECT willDelete( + "old"."id", + 'reminders' + ) + WHERE areTriggersEnabled(); + END + """, + [3]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataInserts" + AFTER INSERT ON "reminders" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName", "userModificationDate") + SELECT + 'reminders', + "new"."id", + datetime('subsec') + WHERE areTriggersEnabled() + ON CONFLICT("zoneName", "recordName") DO NOTHING; + END + """, + [4]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataUpdates" + AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName") + SELECT 'reminders', "new"."id" + WHERE areTriggersEnabled() + ON CONFLICT("zoneName", "recordName") DO UPDATE SET + "userModificationDate" = datetime('subsec'); + END + """, + [5]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_remindersLists_onDeleteCascade" + AFTER DELETE ON "remindersLists" + FOR EACH ROW BEGIN + DELETE FROM "reminders" + WHERE "remindersListID" = "old"."id"; + END + """, + [6]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_insert_remindersLists" + AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'remindersLists' + ) + WHERE areTriggersEnabled(); + END + """, + [7]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_update_remindersLists" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'remindersLists' + ) + WHERE areTriggersEnabled(); + END + """, + [8]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_delete_remindersLists" + BEFORE DELETE ON "remindersLists" FOR EACH ROW BEGIN + SELECT willDelete( + "old"."id", + 'remindersLists' + ) + WHERE areTriggersEnabled(); + END + """, + [9]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataInserts" + AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName", "userModificationDate") + SELECT + 'remindersLists', + "new"."id", + datetime('subsec') + WHERE areTriggersEnabled() + ON CONFLICT("zoneName", "recordName") DO NOTHING; + END + """, + [10]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataUpdates" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName") + SELECT 'remindersLists', "new"."id" + WHERE areTriggersEnabled() + ON CONFLICT("zoneName", "recordName") DO UPDATE SET + "userModificationDate" = datetime('subsec'); + END + """ + ] + """# } try await syncEngine.tearDownSyncEngine() - try await database.read { db in - assertInlineSnapshot( - of: try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db), - as: .customDump - ) { - """ - [] - """ - } + sqls = try await database.write { db in + try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) + } + assertInlineSnapshot(of: sqls, as: .customDump) { + """ + [] + """ } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func insert() async throws { - try await database.write { db in + @Test func insertUpdateDelete() throws { + try database.write { db in try RemindersList - .insert(RemindersList.Draft(title: "Personal")) + .insert(RemindersList(id: UUID(1), title: "Personal")) .execute(db) } - try await Task.sleep(for: .seconds(1)) - #expect(underlyingSyncState.pendingRecordZoneChanges == []) - } - - @Test func inMemoryAttachment() throws { - print(#function) - let d1 = try DatabaseQueue(named: "d1") - let d1_ = try DatabaseQueue(named: "d1") - let d2 = try DatabaseQueue(named: "d2") - - try d1.write { db in - try #sql("create table t1 (id integer)").execute(db) - try #sql("insert into t1 (id) values (1), (2), (3)").execute(db) - } - try d2.write { db in - try #sql("create table t2 (id integer)").execute(db) - try #sql("insert into t2 (id) values (10), (20), (30)").execute(db) - } - try d1_.read { db in - try #expect(#sql("select id from t1", as: Int.self).fetchAll(db) == [1, 2, 3]) + underlyingSyncState.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + ]) + try database.write { db in + try RemindersList + .find(UUID(1)) + .update { $0.title = "Work" } + .execute(db) } - try d2.read { db in - try #expect(#sql("select id from t2", as: Int.self).fetchAll(db) == [10, 20, 30]) + underlyingSyncState.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + ]) + try database.write { db in + try RemindersList + .find(UUID(1)) + .delete() + .execute(db) } + underlyingSyncState.assertPendingRecordZoneChanges([ + .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + ]) + } - try d2.write { db in - try #sql("attach database 'file:d1?mode=memory&cache=shared' as 'd1'").execute(db) - } - try d2.read { db in - try #expect(#sql("select id from d1.t1", as: Int.self).fetchAll(db) == [1, 2, 3]) - try #expect(#sql("select id from t2", as: Int.self).fetchAll(db) == [10, 20, 30]) - } - try d2.read { db in - try #sql("DETACH DATABASE d1").execute(db) - } - try d2.write { db in - withKnownIssue { - try #expect(#sql("select id from d1.t1", as: Int.self).fetchAll(db) == [1, 2, 3]) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteCascade() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(2), title: "Walk", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Haircut", remindersListID: UUID(1)) } - try #expect(#sql("select id from t2", as: Int.self).fetchAll(db) == [10, 20, 30]) } + underlyingSyncState.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + ]) + try database.write { db in + try RemindersList.find(UUID(1)).delete().execute(db) + } + underlyingSyncState.assertPendingRecordZoneChanges([ + .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .deleteRecord(CKRecord.ID(UUID(1), in: Reminder.self)), + .deleteRecord(CKRecord.ID(UUID(2), in: Reminder.self)), + .deleteRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + ]) } - @Test func detatchFromWriteProblem() throws { - print(#function) - let d2 = try DatabaseQueue(named: "d2") - try d2.write { db in - try #sql("attach database 'file:d1?mode=memory&cache=shared' as 'd1'").execute(db) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordUpdate() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } } - try d2.write { db in - try #sql("DETACH DATABASE d1").execute(db) + underlyingSyncState.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + ]) + + let recordID = CKRecord.ID(UUID(1), in: RemindersList.self) + let record = CKRecord( + recordType: "remindersLists", + recordID: recordID + ) + // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? + record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString + record.encryptedValues[RemindersList.columns.title.name] = "Work" + record.userModificationDate = Date.distantFuture + await syncEngine.handleFetchedRecordZoneChanges( + modifications: [record], + deletions: [] + ) + expectNoDifference( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + RemindersList(id: UUID(1), title: "Work") + ) + + let metadata = try await database.write { db in + try Metadata.find(recordID: recordID).fetchOne(db) } + #expect(record == metadata?.lastKnownServerRecord) } } -@Table private struct Reminder: Identifiable { - let id: Int +extension CKRecord.ID { + convenience init( + _ id: T.TableColumns.PrimaryKey, + in table: T.Type + ) + where T.TableColumns.PrimaryKey == UUID { + self.init( + recordName: id.uuidString, + zoneID: CKRecordZone.ID(zoneName: T.tableName) + ) + } +} + +@Table private struct Reminder: Equatable, Identifiable { + let id: UUID var title = "" var remindersListID: RemindersList.ID } -@Table private struct RemindersList: Identifiable { - let id: Int +@Table private struct RemindersList: Equatable, Identifiable { + let id: UUID var title = "" } -private func database() throws -> any DatabaseWriter { +private func database() throws -> DatabasePool { var configuration = Configuration() configuration.foreignKeysEnabled = false configuration.prepareDatabase { db in - db.trace { print($0) } + db.trace { print($0.expandedDescription) } } - let database = try DatabaseQueue(configuration: configuration) + let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") + let database = try DatabasePool(path: url.path(), configuration: configuration) var migrator = DatabaseMigrator() migrator.registerMigration("Create tables") { db in try #sql( """ CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT PRIMARY KEY DEFAULT (uuid()), "title" TEXT NOT NULL ) STRICT """ @@ -268,9 +319,9 @@ private func database() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT PRIMARY KEY DEFAULT (uuid()), "title" TEXT NOT NULL, - "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -279,48 +330,3 @@ private func database() throws -> any DatabaseWriter { try migrator.migrate(database) return database } - -final class MockSyncEngine: CKSyncEngineProtocol { - let _engineState: LockIsolated - init(engineState: any CKSyncEngineStateProtocol) { - self._engineState = LockIsolated(engineState) - } - var engineState: any CKSyncEngineStateProtocol { - _engineState.withValue(\.self) - } - func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { - } -} - -final class MockSyncEngineState: CKSyncEngineStateProtocol { - private let _pendingRecordZoneChanges = LockIsolated<[CKSyncEngine.PendingRecordZoneChange]>([]) - private let _pendingDatabaseChanges = LockIsolated<[CKSyncEngine.PendingDatabaseChange]>([]) - - var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { - _pendingRecordZoneChanges.withValue(\.self) - } - var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { - _pendingDatabaseChanges.withValue(\.self) - } - - func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.append(contentsOf: pendingRecordZoneChanges) - } - } - func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.removeAll(where: pendingRecordZoneChanges.contains) - } - } - func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.append(contentsOf: pendingDatabaseChanges) - } - } - func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.removeAll(where: pendingDatabaseChanges.contains) - } - } -} diff --git a/Tests/SharingGRDBTests/Internal/MockCloudKit.swift b/Tests/SharingGRDBTests/Internal/MockCloudKit.swift new file mode 100644 index 00000000..c0ccd772 --- /dev/null +++ b/Tests/SharingGRDBTests/Internal/MockCloudKit.swift @@ -0,0 +1,98 @@ +import CloudKit +import ConcurrencyExtras +import CustomDump +import SharingGRDBCore + +final class MockSyncEngine: CKSyncEngineProtocol { + let _engineState: LockIsolated + init(engineState: any CKSyncEngineStateProtocol) { + self._engineState = LockIsolated(engineState) + } + var engineState: any CKSyncEngineStateProtocol { + _engineState.withValue(\.self) + } + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { + } +} + +final class MockSyncEngineState: CKSyncEngineStateProtocol { + private let _pendingRecordZoneChanges = LockIsolated>([]) + private let _pendingDatabaseChanges = LockIsolated>([]) + private let fileID: StaticString + private let filePath: StaticString + private let line: UInt + private let column: UInt + + init( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } + + func assertPendingRecordZoneChanges( + _ changes: Set, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _pendingRecordZoneChanges.withValue { + expectNoDifference( + changes, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + func assertPendingDatabaseChanges( + _ changes: Set, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _pendingDatabaseChanges.withValue { + expectNoDifference( + changes, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.formUnion(pendingRecordZoneChanges) + } + } + func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.subtract(pendingRecordZoneChanges) + } + } + func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.formUnion(pendingDatabaseChanges) + } + } + func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.subtract(pendingDatabaseChanges) + } + } +} From 7dc3db12e8d11a733194a443c057564507801719 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 19 May 2025 20:35:32 -0700 Subject: [PATCH 055/581] wip --- .../SharingGRDBCore/CloudKit/CKRecord.swift | 14 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 53 +- Sources/SharingGRDBCore/CloudKitOld.swift | 953 ------------------ Tests/SharingGRDBTests/CloudKitTests.swift | 79 +- 4 files changed, 120 insertions(+), 979 deletions(-) delete mode 100644 Sources/SharingGRDBCore/CloudKitOld.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift index b60d14bd..67e3e34c 100644 --- a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift +++ b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift @@ -1,4 +1,5 @@ import CloudKit +import CustomDump import StructuredQueriesCore extension CKRecord { @@ -81,3 +82,16 @@ extension PrimaryKeyedTable { } } } + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +extension CKRecord: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + return Mirror( + self, + children: self.allKeys().map { + ($0, self.encryptedValues[$0] as Any) + }, + displayStyle: .struct + ) + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 3b6dc71b..87972160 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -25,7 +25,6 @@ public final actor SyncEngine { database: any DatabaseWriter, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { - fatalError() self.init( defaultSyncEngine: { syncEngine in CKSyncEngine( @@ -219,6 +218,25 @@ public final actor SyncEngine { """ ) .execute(db) + + + // TODO: do we want this? + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" + AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN + DELETE FROM \(Metadata.self) + WHERE areTriggersEnabled() + AND "zoneName" = '\(raw: table.tableName)' + AND "recordName" = "old".\(quote: T.columns.primaryKey.name); + END + """ + ) + .execute(db) + + + let foreignKeys = try SQLQueryExpression( """ SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) @@ -415,6 +433,12 @@ public final actor SyncEngine { continue } } + try SQLQueryExpression( + """ + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" + """ + ) + .execute(db) try SQLQueryExpression( """ DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_metadataUpdates" @@ -512,7 +536,7 @@ public final actor SyncEngine { } logger.debug( """ - SharingGRDB: Metadatabase connection: + Metadatabase connection: open "\(self.metadatabaseURL.path(percentEncoded: false))" """ ) @@ -537,15 +561,7 @@ extension SyncEngine: CKSyncEngineDelegate { case .accountChange(let event): handleAccountChange(event) case .stateUpdate(let event): - //stateSerialization = event.stateSerialization - withErrorReporting(.sharingGRDBCloudKitFailure) { - try database.write { db in - try StateSerialization.insert( - StateSerialization(data: event.stateSerialization) - ) - .execute(db) - } - } + handleStateUpdate(event) case .fetchedDatabaseChanges(let event): handleFetchedDatabaseChanges(event) case .sentDatabaseChanges: @@ -602,7 +618,7 @@ extension SyncEngine: CKSyncEngineDelegate { .joined(separator: ", ") logger.debug( """ - SharingGRDB: nextRecordZoneChangeBatch: \(context.reason) + nextRecordZoneChangeBatch: \(context.reason) \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") @@ -704,6 +720,17 @@ extension SyncEngine: CKSyncEngineDelegate { } } + private func handleStateUpdate(_ event: CKSyncEngine.Event.StateUpdate) { + withErrorReporting(.sharingGRDBCloudKitFailure) { + try database.write { db in + try StateSerialization.insert( + StateSerialization(data: event.stateSerialization) + ) + .execute(db) + } + } + } + private func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in @@ -1106,7 +1133,7 @@ private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Logger { func log(_ event: CKSyncEngine.Event) { - let prefix = "SharingGRDB: handleEvent:" + let prefix = "handleEvent:" switch event { case .stateUpdate: debug("\(prefix) stateUpdate") diff --git a/Sources/SharingGRDBCore/CloudKitOld.swift b/Sources/SharingGRDBCore/CloudKitOld.swift deleted file mode 100644 index 7ce2d28f..00000000 --- a/Sources/SharingGRDBCore/CloudKitOld.swift +++ /dev/null @@ -1,953 +0,0 @@ -//#if canImport(CloudKit) -// import CloudKit -// import Dependencies -// import OSLog -// -// extension DependencyValues { -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// public var cloudKitDatabase: CloudKitDatabase { -// get { self[CloudKitDatabase.self] } -// set { self[CloudKitDatabase.self] = newValue } -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// extension CloudKitDatabase: TestDependencyKey { -// public static var testValue: CloudKitDatabase { -// if shouldReportUnimplemented { -// reportIssue("TODO") -// } -// return try! CloudKitDatabase( -// container: CKContainer(identifier: "default"), -// database: try! DatabaseQueue(), -// tables: [] -// ) -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// public actor CloudKitDatabase { -// let container: CKContainer -// let database: any DatabaseWriter -// var syncEngine: CKSyncEngine! -// var stateSerialization: CKSyncEngine.State.Serialization? -// let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] -// var delegate: Delegate -// -// public init( -// container: CKContainer, -// database: any DatabaseWriter, -// tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] -// ) throws { -// self.container = container -// self.database = database -// self.delegate = Delegate(container: container) -// self.tables = tables -// let stateSerializationData = -// UserDefaults.standard.data( -// forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) -// ) ?? Data() -// stateSerialization = try? JSONDecoder().decode( -// CKSyncEngine.State.Serialization.self, -// from: stateSerializationData -// ) -// let configuration = CKSyncEngine.Configuration( -// database: container.privateCloudDatabase, -// stateSerialization: stateSerialization, -// delegate: delegate -// ) -// let syncEngine = CKSyncEngine(configuration) -// self.syncEngine = syncEngine -// delegate.syncEngine = syncEngine -// try? FileManager.default -// .createDirectory( -// at: URL.applicationSupportDirectory, -// withIntermediateDirectories: false -// ) -// let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") -// logger.info("open \(url.absoluteString)") -// let cloudKitDatabase = try DatabasePool(path: url.absoluteString) -// var migrator = DatabaseMigrator() -// migrator.registerMigration("Create SharingGRDB tables") { db in -// try SQLQueryExpression( -// """ -// CREATE TABLE "sharing_grdb_cloudkit" ( -// "tableName" TEXT NOT NULL, -// "primaryKey" TEXT NOT NULL, -// "recordData" BLOB, -// "userModificationDate" TEXT, -// PRIMARY KEY("tableName", "primaryKey") -// ) -// """ -// ) -// .execute(db) -// } -// try migrator.migrate(cloudKitDatabase) -// try database.write { db in -// try db.execute( -// literal: """ -// ATTACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" -// """ -// ) -// try createTriggers(db: db, cloudKitDatabase: self) -// } -// Self.saveZones(syncEngine: syncEngine, tables: tables) -// } -// -// deinit { -// print("?!?!?!") -// } -// -// func tearDownSyncEngine() throws { -// let url = URL.applicationSupportDirectory.appending(component: "sharing-grdb-cloudkit.sqlite") -// try database.write { db in -// try dropTriggers(db: db, tables: tables) -// try db.execute( -// literal: """ -// DETACH DATABASE \(url.absoluteString) AS "sharing_grdb_cloudkit_db" -// """ -// ) -// } -// try? FileManager.default.removeItem(at: url) -// } -// -// func restartSyncEngine() throws { -// try tearDownSyncEngine() -// // setUpSyncEngine() -// -// // delete triggers -// // delete all data from tables -// // detach metadata database -// // delete metadata database -// // everything in initializer -// -// UserDefaults.standard.removeObject( -// forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) -// ) -// stateSerialization = nil -// self.delegate = Delegate(container: container) -// let configuration = CKSyncEngine.Configuration( -// database: container.privateCloudDatabase, -// stateSerialization: stateSerialization, -// delegate: delegate -// ) -// syncEngine = CKSyncEngine(configuration) -// delegate.syncEngine = syncEngine -// saveZones() -// } -// -// static func saveZones( -// syncEngine: CKSyncEngine, -// tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] -// ) { -// syncEngine.state.add( -// pendingDatabaseChanges: tables.map { -// .saveZone(CKRecordZone(zoneName: $0.tableName)) -// } -// ) -// } -// -// func saveZones() { -// Self.saveZones(syncEngine: syncEngine, tables: tables) -// } -// -// func didInsert(tableName: String, id: String) { -// syncEngine.state.add( -// pendingRecordZoneChanges: [ -// .saveRecord( -// CKRecord.ID( -// recordName: id, -// zoneID: CKRecordZone(zoneName: tableName).zoneID -// ) -// ) -// ] -// ) -// } -// -// func didUpdate(tableName: String, id: String) { -// // TODO: perform modification date checks -// syncEngine.state.add( -// pendingRecordZoneChanges: [ -// .saveRecord( -// CKRecord.ID( -// recordName: id, -// zoneID: CKRecordZone(zoneName: tableName).zoneID -// ) -// ) -// ] -// ) -// } -// -// func willDelete(tableName: String, id: String) { -// syncEngine.state.add( -// pendingRecordZoneChanges: [ -// .deleteRecord( -// CKRecord.ID( -// recordName: id, -// zoneID: CKRecordZone(zoneName: tableName).zoneID -// ) -// ) -// ] -// ) -// } -// -// #if DEBUG -// public func deleteAllRecords() async throws { -// syncEngine.state.add( -// pendingDatabaseChanges: tables.map { table in -// .deleteZone(CKRecordZone.ID(zoneName: table.tableName)) -// } -// ) -// try await syncEngine.sendChanges() -// } -// #endif -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// final class Delegate: CKSyncEngineDelegate, @unchecked Sendable { -// @Dependency(\.defaultDatabase) var database -// let container: CKContainer -// var syncEngine: CKSyncEngine! -// init(container: CKContainer) { -// self.container = container -// } -// -// func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { -// logger.info("CloudKitDatabase.Delegate.handleEvent.\(event)") -// switch event { -// case .stateUpdate(let stateUpdate): -// withErrorReporting { -// UserDefaults.standard.set( -// try JSONEncoder().encode(stateUpdate.stateSerialization), -// forKey: stateSerializationKey(containerIdentifier: container.containerIdentifier) -// ) -// } -// break -// case .accountChange(_): -// // TODO -// break -// case .fetchedDatabaseChanges(let changes): -// handleFetchedDatabaseChanges(changes) -// break -// case .fetchedRecordZoneChanges(let changes): -// handleFetchedRecordZoneChanges(changes) -// break -// case .sentDatabaseChanges(_): -// // TODO -// break -// case .sentRecordZoneChanges(let changes): -// handleSentRecordZoneChanges(changes) -// break -// case .willFetchChanges(_): -// // TODO -// break -// case .willFetchRecordZoneChanges(_): -// // TODO -// break -// case .didFetchRecordZoneChanges(_): -// // TODO -// break -// case .didFetchChanges(_): -// // TODO -// break -// case .willSendChanges(_): -// // TODO -// break -// case .didSendChanges(_): -// // TODO -// break -// @unknown default: -// // TODO -// break -// } -// } -// -// private func handleSentRecordZoneChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) { -// var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() -// var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() -// defer { -// syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) -// syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) -// } -// -// withErrorReporting { -// try database.write { db in -// for savedRecord in changes.savedRecords { -// try db.cacheNewRecordIfNewer(savedRecord) -// } -// -// for failedRecordSave in changes.failedRecordSaves { -// // TODO: do this -// switch failedRecordSave.error.code { -// // case .internalError: -// // <#code#> -// // case .partialFailure: -// // <#code#> -// // case .networkUnavailable: -// // <#code#> -// // case .networkFailure: -// // <#code#> -// // case .badContainer: -// // <#code#> -// // case .serviceUnavailable: -// // <#code#> -// // case .requestRateLimited: -// // <#code#> -// // case .missingEntitlement: -// // <#code#> -// // case .notAuthenticated: -// // <#code#> -// // case .permissionFailure: -// // <#code#> -// case .unknownItem: -// print("") -// // case .invalidArguments: -// // <#code#> -// // case .resultsTruncated: -// // <#code#> -// case .serverRecordChanged: -// guard let serverRecord = failedRecordSave.error.serverRecord -// else { continue } -// try db.cacheNewRecordIfNewer(serverRecord) -// try serverRecord.upsertIfNewer(db: db) -// print( -// serverRecord.recordID, -// failedRecordSave.record.recordID, -// serverRecord.recordID == failedRecordSave.record.recordID -// ) -// newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) -// // case .serverRejectedRequest: -// // <#code#> -// // case .assetFileNotFound: -// // <#code#> -// // case .assetFileModified: -// // <#code#> -// // case .incompatibleVersion: -// // <#code#> -// // case .constraintViolation: -// // <#code#> -// // case .operationCancelled: -// // <#code#> -// // case .changeTokenExpired: -// // <#code#> -// // case .batchRequestFailed: -// // <#code#> -// // case .zoneBusy: -// // <#code#> -// // case .badDatabase: -// // <#code#> -// // case .quotaExceeded: -// // <#code#> -// case .zoneNotFound: -// // TODO: recreate zone if it matches a table name? -// let zone = CKRecordZone(zoneID: failedRecordSave.record.recordID.zoneID) -// newPendingDatabaseChanges.append(.saveZone(zone)) -// newPendingRecordZoneChanges.append(.saveRecord(failedRecordSave.record.recordID)) -// -// // case .limitExceeded: -// // <#code#> -// // case .userDeletedZone: -// // <#code#> -// // case .tooManyParticipants: -// // <#code#> -// // case .alreadyShared: -// // <#code#> -// // case .referenceViolation: -// // <#code#> -// // case .managedAccountRestricted: -// // <#code#> -// // case .participantMayNeedVerification: -// // <#code#> -// // case .serverResponseLost: -// // <#code#> -// // case .assetNotAvailable: -// // <#code#> -// // case .accountTemporarilyUnavailable: -// // <#code#> -// -// case .networkFailure, -// .networkUnavailable, -// .zoneBusy, -// .serviceUnavailable, -// .notAuthenticated, -// .operationCancelled: -// print("") -// default: -// reportIssue("Unhandled error: \(failedRecordSave.error.code)") -// } -// } -// -// for (recordID, failedRecordDelete) in changes.failedRecordDeletes { -// // TODO: do this -// print(failedRecordDelete) -// } -// -// // TODO: double check this is correct. the sample code doesn't have this -// for deletedRecordID in changes.deletedRecordIDs { -// try deletedRecordID.delete(db: db) -// } -// } -// } -// } -// -// private func handleFetchedRecordZoneChanges( -// _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges -// ) { -// withErrorReporting { -// try database.write { db in -// for modification in changes.modifications { -// try modification.record.upsertIfNewer(db: db) -// try db.cacheNewRecordIfNewer(modification.record) -// } -// -// for deletion in changes.deletions { -// try deletion.recordID.delete(db: db) -// } -// } -// } -// } -// -// private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) -// { -// withErrorReporting { -// try database.write { db in -// for deletion in changes.deletions { -// let tableName = deletion.zoneID.zoneName -// try SQLQueryExpression( -// """ -// DELETE FROM "\(raw: tableName)" -// """ -// ) -// .execute(db) -// -// syncEngine.state.add( -// pendingDatabaseChanges: [ -// .saveZone(CKRecordZone(zoneName: tableName)) -// ] -// ) -// } -// } -// } -// } -// -// func nextRecordZoneChangeBatch( -// _ context: CKSyncEngine.SendChangesContext, -// syncEngine: CKSyncEngine -// ) async -> CKSyncEngine.RecordZoneChangeBatch? { -// logger.info("CloudKitDatabase.Delegate.nextRecordZoneChangeBatch \(context)") -// -// let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) -// let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in -// do { -// return try database.write { db in -// let record = try db.fetchLastCachedRecord(id: recordID) -// let row = try Row.fetchOne( -// db, -// SQLRequest( -// sql: """ -// SELECT * FROM "\(recordID.tableName)" WHERE "id" = ? -// """, -// arguments: [recordID.primaryKey] -// ) -// ) -// -// guard let row -// else { -// syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) -// return nil -// } -// record.update(with: row) -// try db.cacheNewRecordIfNewer(record) -// return record -// } -// } catch { -// reportIssue(error) -// return nil -// } -// } -// return batch -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// extension CKRecord { -// func update(with row: Row) { -// for columnName in row.columnNames { -// switch row[columnName]?.databaseValue.storage { -// case .null: -// if encryptedValues[columnName] != nil { -// encryptedValues[columnName] = nil -// } -// case .int64(let value): -// if object(forKey: columnName) as? Int64 != value { -// encryptedValues[columnName] = value -// } -// case .double(let value): -// if object(forKey: columnName) as? Double != value { -// encryptedValues[columnName] = value -// } -// case .string(let value): -// if object(forKey: columnName) as? String != value { -// encryptedValues[columnName] = value -// } -// case .blob(let value): -// if object(forKey: columnName) as? Data != value { -// encryptedValues[columnName] = value -// } -// case .none: -// break -// } -// } -// } -// } -// -// extension CKRecord.ID { -// fileprivate var primaryKey: String { recordName } -// fileprivate var tableName: String { zoneID.zoneName } -// } -// -// private func stateSerializationKey(containerIdentifier: String?) -> String { -// (containerIdentifier ?? "") + ".stateSerializationData" -// } -// -// extension Database { -// func cacheNewRecordIfNewer(_ newRecord: CKRecord) throws { -// let existingRecord = try fetchLastCachedRecord(id: newRecord.recordID) -// if let existingRecordModificationDate = existingRecord.modificationDate { -// if let newRecordModificationDate = newRecord.modificationDate, -// existingRecordModificationDate < newRecordModificationDate -// { -// try update() -// } else { -// print("Modification date caught") -// } -// } else { -// try update() -// } -// -// func update() throws { -// let archiver = NSKeyedArchiver(requiringSecureCoding: true) -// newRecord.encodeSystemFields(with: archiver) -// // TODO: should we use userModificationDate based on record.modificationDate? -// try SQLQueryExpression( -// """ -// INSERT INTO "sharing_grdb_cloudkit" -// ("tableName", "primaryKey", "recordData", "userModificationDate") -// VALUES ( -// \(bind: newRecord.recordID.tableName), -// \(bind: newRecord.recordID.primaryKey), -// \(archiver.encodedData), -// \(bind: Date.ISO8601Representation(queryOutput: .distantPast)) -// ) -// ON CONFLICT("tableName", "primaryKey") DO UPDATE SET -// "recordData" = \(archiver.encodedData) -// """ -// ) -// .execute(self) -// } -// } -// -// func fetchLastCachedRecord(id recordID: CKRecord.ID) throws -> CKRecord { -// return try SQLQueryExpression( -// """ -// SELECT "recordData" -// FROM "sharing_grdb_cloudkit" -// WHERE "tableName" = \(bind: recordID.tableName) -// AND "primaryKey" = \(bind: recordID.primaryKey) -// """, -// as: Data?.self -// ) -// .fetchOne(self) -// .flatMap { $0 } -// .flatMap { data in -// let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) -// unarchiver.requiresSecureCoding = true -// return CKRecord(coder: unarchiver) -// } -// ?? CKRecord(recordType: recordID.tableName, recordID: recordID) -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// extension CKRecord { -// func upsertIfNewer(db: Database) throws { -// let userModificationDate = -// try SQLQueryExpression( -// """ -// SELECT "userModificationDate" FROM "sharing_grdb_cloudkit" -// WHERE "tableName" = \(bind: recordID.tableName) -// AND "primaryKey" = \(bind: recordID.primaryKey) -// """, -// as: Date?.ISO8601Representation.self -// ) -// .fetchOne(db) -// ?? nil -// -// if let userModificationDate, -// userModificationDate > (modificationDate ?? .distantPast) -// { -// print("Modification date caught") -// } else { -// // TODO: can we use record.keysChanged to update only columns that changed? -// let columnNames = try String.fetchAll( -// db, -// sql: """ -// SELECT "name" -// FROM pragma_table_info('\(recordID.tableName)') -// """ -// ) -// var query: QueryFragment = """ -// INSERT INTO "\(raw: recordID.tableName)" ( -// """ -// query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ",")) -// query.append( -// """ -// ) VALUES ( -// """ -// ) -// query.append( -// columnNames.map { columnName in -// "\(bind: convert(encryptedValues[columnName]))" -// }.joined(separator: ",") -// ) -// query.append( -// """ -// ) ON CONFLICT("id") DO UPDATE SET -// """ -// ) -// query.append( -// columnNames -// .map { " \(quote: $0) = excluded.\(quote: $0)" } -// .joined(separator: ",") -// ) -// try SQLQueryExpression(query).execute(db) -// } -// } -// } -// -// extension CKRecord.ID { -// func delete(db: Database) throws { -// try SQLQueryExpression( -// """ -// DELETE FROM "\(raw: tableName)" -// WHERE "id" = \(bind: primaryKey) -// """ -// ) -// .execute(db) -// } -// } -// -// extension CKRecordZone.ID { -// func deleteAll(db: Database) throws { -// try SQLQueryExpression( -// """ -// DELETE FROM "\(raw: zoneName)" -// """ -// ) -// .execute(db) -// } -// } -// -// private func convert(_ value: (any __CKRecordObjCValue)?) -> any QueryExpression { -// guard let value else { -// // TODO: better way? -// return SQLQueryExpression("NULL", as: Void?.self) -// } -// if let value = value as? Int64 { -// return value -// } else if let value = value as? Double { -// return value -// } else if let value = value as? String { -// return value -// } else if let value = value as? Data { -// return value -// } else { -// fatalError("TODO: do we need to do all numeric types?") -// } -// } -// -// extension DatabaseFunction { -// fileprivate convenience init( -// name: String, -// function: @escaping @Sendable (String, String) async -> Void -// ) { -// self.init(name, argumentCount: 2) { arguments in -// guard -// let tableName = String.fromDatabaseValue(arguments[0]), -// let id = String.fromDatabaseValue(arguments[1]) -// else { -// return 0 -// } -// Task { await function(tableName, id) } -// return 0 -// } -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// func dropTriggers( -// db: Database, -// tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] -// ) throws { -// db.remove(function: .didInsert) -// db.remove(function: .didUpdate) -// db.remove(function: .willDelete) -// for table in tables { -// try SQLQueryExpression( -// """ -// DROP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" -// """ -// ) -// .execute(db) -// let foreignKeys = try SQLQueryExpression( -// """ -// SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) -// """, -// as: PragmaForeignKey.self -// ) -// .fetchAll(db) -// for foreignKey in foreignKeys { -// switch foreignKey.onDelete { -// case .cascade: -// try SQLQueryExpression( -// """ -// DROP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" -// """ -// ) -// .execute(db) -// case .restrict: -// fatalError("TODO: report issue?") -// case .setDefault: -// fatalError("TODO: report issue?") -// case .setNull: -// try SQLQueryExpression( -// """ -// DROP TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" -// """ -// ) -// .execute(db) -// case .noAction: -// continue -// } -// -// switch foreignKey.onUpdate { -// case .cascade: -// fatalError("TODO") -// case .restrict: -// fatalError("TODO") -// case .setDefault: -// fatalError("TODO") -// case .setNull: -// fatalError("TODO") -// case .noAction: -// continue -// } -// } -// } -// } -// -// @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -// func createTriggers( -// db: Database, -// cloudKitDatabase: CloudKitDatabase -// ) throws { -// db.add(function: .didInsert) -// db.add(function: .didUpdate) -// db.add(function: .willDelete) -// for table in cloudKitDatabase.tables { -// try Trigger.delete(tableName: table.tableName).sql -// .execute(db) -// try Trigger.insert(tableName: table.tableName).sql -// .execute(db) -// try Trigger.update(tableName: table.tableName).sql -// .execute(db) -// try SQLQueryExpression( -// """ -// CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: table.tableName)_userModificationDate" -// AFTER UPDATE ON \(table) FOR EACH ROW BEGIN -// INSERT INTO "sharing_grdb_cloudkit" -// ("tableName", "primaryKey", "userModificationDate") -// VALUES -// ( -// '\(raw: table.tableName)', -// new."id", -// datetime('subsec') -// ) -// ON CONFLICT("tableName", "primaryKey") DO UPDATE SET -// "userModificationDate" = excluded."userModificationDate"; -// END -// """ -// ) -// .execute(db) -// let foreignKeys = try SQLQueryExpression( -// """ -// SELECT \(PragmaForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) -// """, -// as: PragmaForeignKey.self -// ) -// .fetchAll(db) -// for foreignKey in foreignKeys { -// switch foreignKey.onDelete { -// case .cascade: -// try SQLQueryExpression( -// """ -// CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" -// AFTER DELETE ON \(quote: foreignKey.table) -// FOR EACH ROW BEGIN -// DELETE FROM \(quote: table.tableName) -// WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); -// END -// """ -// ) -// .execute(db) -// case .restrict: -// fatalError("TODO: report issue?") -// case .setDefault: -// fatalError("TODO: report issue?") -// case .setNull: -// try SQLQueryExpression( -// """ -// CREATE TEMP TRIGGER "foreign_key_\(raw: table.tableName)_belongsTo_\(raw: foreignKey.table)" -// AFTER DELETE ON \(quote: foreignKey.table) -// FOR EACH ROW BEGIN -// UPDATE \(quote: table.tableName) -// SET \(quote: foreignKey.from) = NULL -// WHERE \(quote: foreignKey.from) = old.\(quote: foreignKey.to); -// END -// """ -// ) -// .execute(db) -// case .noAction: -// continue -// } -// -// switch foreignKey.onUpdate { -// case .cascade: -// fatalError("TODO") -// case .restrict: -// fatalError("TODO") -// case .setDefault: -// fatalError("TODO") -// case .setNull: -// fatalError("TODO") -// case .noAction: -// continue -// } -// } -// } -// } -// -// private struct PragmaForeignKey: QueryDecodable, QueryRepresentable { -// enum Action: String, QueryBindable { -// case cascade = "CASCADE" -// case restrict = "RESTRICT" -// case setDefault = "SET DEFAULT" -// case setNull = "SET NULL" -// case noAction = "NO ACTION" -// } -// -// typealias QueryValue = Self -// -// let table: String -// let from: String -// let to: String -// let onUpdate: Action -// let onDelete: Action -// -// init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { -// guard -// let table = try decoder.decode(String.self), -// let from = try decoder.decode(String.self), -// let to = try decoder.decode(String.self), -// let onUpdate = try decoder.decode(Action.self), -// let onDelete = try decoder.decode(Action.self) -// else { -// throw QueryDecodingError.missingRequiredColumn -// } -// self.table = table -// self.from = from -// self.to = to -// self.onUpdate = onUpdate -// self.onDelete = onDelete -// } -// -// static var columns: QueryFragment { -// """ -// "table", "from", "to", "on_update", "on_delete", "match" -// """ -// } -// } -// -// private struct Trigger { -// let idColumn: String -// let function: String -// let tableName: String -// let type: String -// let when: String -// static func delete(tableName: String) -> Self { -// Trigger( -// idColumn: "old.id", -// function: "willDelete", -// tableName: tableName, -// type: "DELETE", -// when: "BEFORE" -// ) -// } -// static func insert(tableName: String) -> Self { -// Trigger( -// idColumn: "new.id", -// function: "didInsert", -// tableName: tableName, -// type: "INSERT", -// when: "AFTER" -// ) -// } -// static func update(tableName: String) -> Self { -// Trigger( -// idColumn: "new.id", -// function: "didUpdate", -// tableName: tableName, -// type: "UPDATE", -// when: "AFTER" -// ) -// } -// var sql: SQLQueryExpression { -// SQLQueryExpression( -// """ -// CREATE TEMP TRIGGER "sharing_grdb_cloudkit_\(raw: type.lowercased())_\(raw: tableName)" -// \(raw: when) \(raw: type) ON "\(raw: tableName)" FOR EACH ROW BEGIN -// SELECT \(raw: function)('\(raw: tableName)', \(raw: idColumn)); -// END -// """ -// ) -// } -// } -// -// @available(macOS 11, iOS 14, watchOS 7, tvOS 14, *) -// private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") -//#endif -// -//@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -//extension DatabaseFunction { -// fileprivate static var didInsert: Self { -// @Dependency(\.cloudKitDatabase) var cloudKitDatabase -// return Self( -// name: "didInsert", -// function: { await cloudKitDatabase.didInsert(tableName: $0, id: $1) } -// ) -// } -// fileprivate static var didUpdate: Self { -// @Dependency(\.cloudKitDatabase) var cloudKitDatabase -// return Self( -// name: "didUpdate", -// function: { await cloudKitDatabase.didUpdate(tableName: $0, id: $1) } -// ) -// } -// fileprivate static var willDelete: Self { -// @Dependency(\.cloudKitDatabase) var cloudKitDatabase -// return Self( -// name: "willDelete", -// function: { await cloudKitDatabase.willDelete(tableName: $0, id: $1) } -// ) -// } -//} diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift index e7694786..5279c052 100644 --- a/Tests/SharingGRDBTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests.swift @@ -105,6 +105,15 @@ final class CloudKitTests: Sendable { END """, [5]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataDeletes" + AFTER DELETE ON "reminders" FOR EACH ROW BEGIN + DELETE FROM "sharing_grdb_cloudkit_metadata" + WHERE areTriggersEnabled() + AND "zoneName" = 'reminders' + AND "recordName" = "old"."id"; + END + """, + [6]: """ CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -112,7 +121,7 @@ final class CloudKitTests: Sendable { WHERE "remindersListID" = "old"."id"; END """, - [6]: """ + [7]: """ CREATE TRIGGER "sharing_grdb_cloudkit_insert_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN SELECT didUpdate( @@ -122,7 +131,7 @@ final class CloudKitTests: Sendable { WHERE areTriggersEnabled(); END """, - [7]: """ + [8]: """ CREATE TRIGGER "sharing_grdb_cloudkit_update_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN SELECT didUpdate( @@ -132,7 +141,7 @@ final class CloudKitTests: Sendable { WHERE areTriggersEnabled(); END """, - [8]: """ + [9]: """ CREATE TRIGGER "sharing_grdb_cloudkit_delete_remindersLists" BEFORE DELETE ON "remindersLists" FOR EACH ROW BEGIN SELECT willDelete( @@ -142,7 +151,7 @@ final class CloudKitTests: Sendable { WHERE areTriggersEnabled(); END """, - [9]: """ + [10]: """ CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataInserts" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" @@ -155,7 +164,7 @@ final class CloudKitTests: Sendable { ON CONFLICT("zoneName", "recordName") DO NOTHING; END """, - [10]: """ + [11]: """ CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataUpdates" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" @@ -165,6 +174,15 @@ final class CloudKitTests: Sendable { ON CONFLICT("zoneName", "recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); END + """, + [12]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataDeletes" + AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN + DELETE FROM "sharing_grdb_cloudkit_metadata" + WHERE areTriggersEnabled() + AND "zoneName" = 'remindersLists' + AND "recordName" = "old"."id"; + END """ ] """# @@ -189,7 +207,7 @@ final class CloudKitTests: Sendable { .execute(db) } underlyingSyncState.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) try database.write { db in try RemindersList @@ -198,7 +216,7 @@ final class CloudKitTests: Sendable { .execute(db) } underlyingSyncState.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) try database.write { db in try RemindersList @@ -207,7 +225,7 @@ final class CloudKitTests: Sendable { .execute(db) } underlyingSyncState.assertPendingRecordZoneChanges([ - .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) } @@ -246,13 +264,12 @@ final class CloudKitTests: Sendable { } } underlyingSyncState.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) - let recordID = CKRecord.ID(UUID(1), in: RemindersList.self) let record = CKRecord( recordType: "remindersLists", - recordID: recordID + recordID: CKRecord.ID(UUID(1), in: RemindersList.self) ) // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString @@ -267,10 +284,46 @@ final class CloudKitTests: Sendable { RemindersList(id: UUID(1), title: "Work") ) + let metadata = try #require( + try await database.write { db in + try Metadata.find(recordID: record.recordID).fetchOne(db) + } + ) + expectNoDifference(record, metadata.lastKnownServerRecord) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordDeleted() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + underlyingSyncState.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + ]) + + let record = CKRecord( + recordType: "remindersLists", + recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + ) + await syncEngine.handleFetchedRecordZoneChanges( + modifications: [], + deletions: [(record.recordID, record.recordType)] + ) + #expect( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() + == 0 + ) let metadata = try await database.write { db in - try Metadata.find(recordID: recordID).fetchOne(db) + try Metadata.find(recordID: record.recordID).fetchOne(db) + } + #expect(metadata == nil) + + // TODO: Do not enqueue a pending zone change when the delete came the server + withKnownIssue { + underlyingSyncState.assertPendingRecordZoneChanges([]) } - #expect(record == metadata?.lastKnownServerRecord) } } From 6472386bdd5b0237a34f544efee18136a709ac01 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 19 May 2025 20:50:54 -0700 Subject: [PATCH 056/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 14 ++++--- Tests/SharingGRDBTests/CloudKitTests.swift | 39 +++++++++++++++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 87972160..f4abb2de 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -13,6 +13,7 @@ extension DependencyValues { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final actor SyncEngine { nonisolated let database: any DatabaseWriter + let logger: Logger lazy var metadatabase: any DatabaseWriter = try! DatabaseQueue() private let metadatabaseURL: URL nonisolated let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] @@ -23,6 +24,7 @@ public final actor SyncEngine { public init( container: CKContainer, database: any DatabaseWriter, + logger: Logger = Logger(subsystem: "SharingGRDB", category: "CloudKit"), tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { self.init( @@ -38,6 +40,7 @@ public final actor SyncEngine { ) }, database: database, + logger: logger, metadatabaseURL: URL.metadatabase(container: container), tables: tables ) @@ -52,6 +55,7 @@ public final actor SyncEngine { self.init( defaultSyncEngine: { _ in defaultSyncEngine }, database: database, + logger: Logger(.disabled), metadatabaseURL: metadatabaseURL, tables: tables ) @@ -60,6 +64,7 @@ public final actor SyncEngine { private init( defaultSyncEngine: @escaping (SyncEngine) -> any CKSyncEngineProtocol, database: any DatabaseWriter, + logger: Logger, metadatabaseURL: URL, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { @@ -72,6 +77,7 @@ public final actor SyncEngine { ) self.defaultSyncEngine = defaultSyncEngine self.database = database + self.logger = logger self.metadatabaseURL = metadatabaseURL self.tables = tables self.tablesByName = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) @@ -82,7 +88,7 @@ public final actor SyncEngine { } } - func setUpSyncEngine() throws { + package func setUpSyncEngine() throws { defer { underlyingSyncEngine = defaultSyncEngine(self) } metadatabase = try defaultMetadatabase @@ -476,6 +482,7 @@ public final actor SyncEngine { ) .execute(db) } + try metadatabase.close() try FileManager.default.removeItem(at: metadatabaseURL) } @@ -529,7 +536,7 @@ public final actor SyncEngine { private var defaultMetadatabase: any DatabaseWriter { get throws { var configuration = Configuration() - configuration.prepareDatabase { db in + configuration.prepareDatabase { [logger] db in db.trace { logger.trace("\($0.expandedDescription)") } @@ -1127,9 +1134,6 @@ extension URL { } } -@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Logger { func log(_ event: CKSyncEngine.Event) { diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift index 5279c052..09de60f0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests.swift @@ -199,6 +199,42 @@ final class CloudKitTests: Sendable { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDownAndReSetUp() async throws { + try await syncEngine.tearDownSyncEngine() + try await syncEngine.setUpSyncEngine() + // TODO: it would be nice if `setUpSyncEngine` was async + try await Task.sleep(for: .seconds(0.1)) + + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + underlyingSyncState.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + ]) + + let record = CKRecord( + recordType: "remindersLists", + recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + ) + await syncEngine.handleFetchedRecordZoneChanges( + modifications: [record], + deletions: [] + ) + expectNoDifference( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + RemindersList(id: UUID(1), title: "Personal") + ) + + let metadata = + try await database.write { db in + try Metadata.find(recordID: record.recordID).fetchOne(db) + } + #expect(metadata != nil) + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func insertUpdateDelete() throws { try database.write { db in @@ -353,9 +389,6 @@ extension CKRecord.ID { private func database() throws -> DatabasePool { var configuration = Configuration() configuration.foreignKeysEnabled = false - configuration.prepareDatabase { db in - db.trace { print($0.expandedDescription) } - } let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") let database = try DatabasePool(path: url.path(), configuration: configuration) var migrator = DatabaseMigrator() From 5f072aef3c429e93d685d8ff8336a734c0092d42 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 May 2025 06:43:11 -0700 Subject: [PATCH 057/581] wip --- Tests/SharingGRDBTests/CloudKitTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift index 09de60f0..3fd3e0b3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests.swift @@ -325,7 +325,8 @@ final class CloudKitTests: Sendable { try Metadata.find(recordID: record.recordID).fetchOne(db) } ) - expectNoDifference(record, metadata.lastKnownServerRecord) + // TODO: is there anything else we can assert on? + #expect(metadata.lastKnownServerRecord != nil) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From 454192bc18402dee8578b9b7129c8e395762bf26 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 May 2025 06:48:45 -0700 Subject: [PATCH 058/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index f4abb2de..970303b9 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -225,7 +225,6 @@ public final actor SyncEngine { ) .execute(db) - // TODO: do we want this? try SQLQueryExpression( """ @@ -241,8 +240,6 @@ public final actor SyncEngine { ) .execute(db) - - let foreignKeys = try SQLQueryExpression( """ SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) From a57972069cbb85e2fe2c1e3deaf67cbab18bbd50 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 May 2025 06:49:24 -0700 Subject: [PATCH 059/581] wip --- Examples/Reminders/Schema.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 7f4bb77d..51464b4b 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -259,16 +259,19 @@ let logger = Logger(subsystem: "Reminders", category: "Database") RemindersList( id: remindersListIDs[0], color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), + position: 0, title: "Personal" ) RemindersList( id: remindersListIDs[1], color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), + position: 1, title: "Family" ) RemindersList( id: remindersListIDs[2], color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), + position: 2, title: "Business" ) From eb063a10215cc7e9139f8abfdbff1c7b287dc9bc Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 May 2025 10:46:53 -0700 Subject: [PATCH 060/581] wip --- Sources/SharingGRDBCore/CloudKit/CKRecord.swift | 4 ++-- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift index 67e3e34c..df3d271b 100644 --- a/Sources/SharingGRDBCore/CloudKit/CKRecord.swift +++ b/Sources/SharingGRDBCore/CloudKit/CKRecord.swift @@ -88,8 +88,8 @@ extension CKRecord: @retroactive CustomDumpReflectable { public var customDumpMirror: Mirror { return Mirror( self, - children: self.allKeys().map { - ($0, self.encryptedValues[$0] as Any) + children: self.allKeys().sorted().map { + ($0, self[$0] as Any) }, displayStyle: .struct ) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 970303b9..2861d71d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -12,12 +12,12 @@ extension DependencyValues { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final actor SyncEngine { - nonisolated let database: any DatabaseWriter + let database: any DatabaseWriter let logger: Logger lazy var metadatabase: any DatabaseWriter = try! DatabaseQueue() private let metadatabaseURL: URL - nonisolated let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - nonisolated let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] var underlyingSyncEngine: (any CKSyncEngineProtocol)! let defaultSyncEngine: (SyncEngine) -> any CKSyncEngineProtocol @@ -224,8 +224,6 @@ public final actor SyncEngine { """ ) .execute(db) - - // TODO: do we want this? try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER From 4656169544d3c9d71c9cd105305d11a7383117d4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 May 2025 11:00:03 -0700 Subject: [PATCH 061/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 31 ------------------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 29 +++++++++++++++-- Tests/SharingGRDBTests/CloudKitTests.swift | 8 +++-- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index b28df953..24cdcfcf 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -36,37 +36,6 @@ extension CKRecord? { package typealias DataRepresentation = CKRecord.DataRepresentation? } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKRecord { - static func `for`(_ row: T) -> CKRecord? { - @Dependency(\.defaultSyncEngine) var defaultSyncEngine - guard let metadatabase = try? DatabasePool(container: defaultSyncEngine.container) - else { return nil } - let record = - withErrorReporting { - try metadatabase.read { db in - try Metadata - .where { - $0.zoneName.eq(T.tableName) - && $0.recordName.eq( - SQLQueryExpression( - T.TableColumns.PrimaryKey( - queryOutput: row[keyPath: T.columns.primaryKey.keyPath] - ) - .queryFragment - ) - ) - } - .select(\.lastKnownServerRecord) - .fetchOne(db) - } - } - ?? nil - guard let record else { return nil } - return record - } -} - @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { func update(with row: T, userModificationDate: Date?) { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 47d70b16..7877f4a9 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -91,7 +91,7 @@ public final actor SyncEngine { package func setUpSyncEngine() throws { defer { underlyingSyncEngine = defaultSyncEngine(self) } - metadatabase = try DatabasePool(container: container) + metadatabase = try defaultMetadatabase var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true @@ -171,7 +171,6 @@ public final actor SyncEngine { } } try database.write { db in - let metadatabaseURL: URL = .metadatabase(container: container) try SQLQueryExpression( "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" ) @@ -569,6 +568,31 @@ public final actor SyncEngine { ] ) } + + private var defaultMetadatabase: any DatabaseWriter { + get throws { + var configuration = Configuration() + configuration.prepareDatabase { [logger] db in + db.trace { + logger.trace("\($0.expandedDescription)") + } + } + logger.debug( + """ + Metadatabase connection: + open "\(self.metadatabaseURL.path(percentEncoded: false))" + """ + ) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + return try DatabaseQueue( + path: metadatabaseURL.path(percentEncoded: false), + configuration: configuration + ) + } + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -658,7 +682,6 @@ extension SyncEngine: CKSyncEngineDelegate { if let sentRecord { $0.sentRecords.append(sentRecord) } } } - } #endif let metadata = await metadataFor(recordID: recordID) diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift index 3fd3e0b3..c8b1565e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests.swift @@ -98,7 +98,9 @@ final class CloudKitTests: Sendable { AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" ("zoneName", "recordName") - SELECT 'reminders', "new"."id" + SELECT + 'reminders', + "new"."id" WHERE areTriggersEnabled() ON CONFLICT("zoneName", "recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); @@ -169,7 +171,9 @@ final class CloudKitTests: Sendable { AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" ("zoneName", "recordName") - SELECT 'remindersLists', "new"."id" + SELECT + 'remindersLists', + "new"."id" WHERE areTriggersEnabled() ON CONFLICT("zoneName", "recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); From 3387e5dd74626b4be8d065d68f74c3a235d4d7de Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 May 2025 11:24:03 -0700 Subject: [PATCH 062/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 21 ++++++------- Tests/SharingGRDBTests/CloudKitTests.swift | 31 ++++++++++--------- .../Internal/MockCloudKit.swift | 10 +++--- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 7877f4a9..ffe8ea18 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -130,7 +130,6 @@ public final actor SyncEngine { } try migrator.migrate(metadatabase) let previousZones = try metadatabase.read { db in - //stateSerialization = try StateSerialization.all.fetchOne(db)?.data return try Zone.all.fetchAll(db) } let currentZones = try database.read { db in @@ -233,7 +232,7 @@ public final actor SyncEngine { AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN DELETE FROM \(Metadata.self) WHERE areTriggersEnabled() - AND "zoneName" = '\(raw: table.tableName)' + AND "zoneName" = \(quote: T.tableName, delimiter: .text) AND "recordName" = "old".\(quote: T.columns.primaryKey.name); END """ @@ -544,7 +543,7 @@ public final actor SyncEngine { } func didUpdate(recordName: String, zoneName: String) { - underlyingSyncEngine.engineState.add( + underlyingSyncEngine.state.add( pendingRecordZoneChanges: [ .saveRecord( CKRecord.ID( @@ -557,7 +556,7 @@ public final actor SyncEngine { } func willDelete(recordName: String, zoneName: String) { - underlyingSyncEngine.engineState.add( + underlyingSyncEngine.state.add( pendingRecordZoneChanges: [ .deleteRecord( CKRecord.ID( @@ -730,7 +729,7 @@ extension SyncEngine: CKSyncEngineDelegate { switch event.changeType { case .signIn: for table in tables { - underlyingSyncEngine.engineState.add( + underlyingSyncEngine.state.add( pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneName: table.tableName))] ) withErrorReporting(.sharingGRDBCloudKitFailure) { @@ -742,7 +741,7 @@ extension SyncEngine: CKSyncEngineDelegate { } return try open(table) } - underlyingSyncEngine.engineState.add( + underlyingSyncEngine.state.add( pendingRecordZoneChanges: names.map { .saveRecord( CKRecord.ID( @@ -835,8 +834,8 @@ extension SyncEngine: CKSyncEngineDelegate { var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] defer { - underlyingSyncEngine.engineState.add(pendingDatabaseChanges: newPendingDatabaseChanges) - underlyingSyncEngine.engineState.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + underlyingSyncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + underlyingSyncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } for failedRecordSave in event.failedRecordSaves { let failedRecord = failedRecordSave.record @@ -1446,9 +1445,10 @@ extension Logger { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package protocol CKSyncEngineProtocol: Sendable { +package protocol CKSyncEngineProtocol: Sendable { + associatedtype State: CKSyncEngineStateProtocol func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws - var engineState: any CKSyncEngineStateProtocol { get } + var state: State { get } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngineProtocol { @@ -1467,7 +1467,6 @@ package protocol CKSyncEngineStateProtocol: Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine: CKSyncEngineProtocol { - package var engineState: any CKSyncEngineStateProtocol { state } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine.State: CKSyncEngineStateProtocol { diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift index c8b1565e..03bce39b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests.swift @@ -11,7 +11,6 @@ final class CloudKitTests: Sendable { let database: any DatabaseWriter let _syncEngine: any Sendable let underlyingSyncEngine: MockSyncEngine - let underlyingSyncState: MockSyncEngineState @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var syncEngine: SyncEngine { @@ -21,10 +20,8 @@ final class CloudKitTests: Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) init() async throws { let database = try SharingGRDBTests.database() - let underlyingSyncState = MockSyncEngineState() - let underlyingSyncEngine = MockSyncEngine(engineState: underlyingSyncState) + let underlyingSyncEngine = MockSyncEngine(state: MockSyncEngineState()) self.database = database - self.underlyingSyncState = underlyingSyncState self.underlyingSyncEngine = underlyingSyncEngine _syncEngine = SyncEngine( defaultSyncEngine: underlyingSyncEngine, @@ -38,8 +35,8 @@ final class CloudKitTests: Sendable { } deinit { - underlyingSyncState.assertPendingDatabaseChanges([]) - underlyingSyncState.assertPendingRecordZoneChanges([]) + underlyingSyncEngine.state.assertPendingDatabaseChanges([]) + underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -215,7 +212,7 @@ final class CloudKitTests: Sendable { RemindersList(id: UUID(1), title: "Personal") } } - underlyingSyncState.assertPendingRecordZoneChanges([ + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) @@ -246,7 +243,7 @@ final class CloudKitTests: Sendable { .insert(RemindersList(id: UUID(1), title: "Personal")) .execute(db) } - underlyingSyncState.assertPendingRecordZoneChanges([ + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) try database.write { db in @@ -255,7 +252,7 @@ final class CloudKitTests: Sendable { .update { $0.title = "Work" } .execute(db) } - underlyingSyncState.assertPendingRecordZoneChanges([ + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) try database.write { db in @@ -264,7 +261,7 @@ final class CloudKitTests: Sendable { .delete() .execute(db) } - underlyingSyncState.assertPendingRecordZoneChanges([ + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) } @@ -279,7 +276,7 @@ final class CloudKitTests: Sendable { Reminder(id: UUID(3), title: "Haircut", remindersListID: UUID(1)) } } - underlyingSyncState.assertPendingRecordZoneChanges([ + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), @@ -288,7 +285,11 @@ final class CloudKitTests: Sendable { try database.write { db in try RemindersList.find(UUID(1)).delete().execute(db) } - underlyingSyncState.assertPendingRecordZoneChanges([ + let reminders = try database.read { db in + try Reminder.all.fetchAll(db) + } + #expect(reminders == []) + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), .deleteRecord(CKRecord.ID(UUID(1), in: Reminder.self)), .deleteRecord(CKRecord.ID(UUID(2), in: Reminder.self)), @@ -303,7 +304,7 @@ final class CloudKitTests: Sendable { RemindersList(id: UUID(1), title: "Personal") } } - underlyingSyncState.assertPendingRecordZoneChanges([ + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) @@ -340,7 +341,7 @@ final class CloudKitTests: Sendable { RemindersList(id: UUID(1), title: "Personal") } } - underlyingSyncState.assertPendingRecordZoneChanges([ + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) ]) @@ -363,7 +364,7 @@ final class CloudKitTests: Sendable { // TODO: Do not enqueue a pending zone change when the delete came the server withKnownIssue { - underlyingSyncState.assertPendingRecordZoneChanges([]) + underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) } } } diff --git a/Tests/SharingGRDBTests/Internal/MockCloudKit.swift b/Tests/SharingGRDBTests/Internal/MockCloudKit.swift index c0ccd772..7f87e6e1 100644 --- a/Tests/SharingGRDBTests/Internal/MockCloudKit.swift +++ b/Tests/SharingGRDBTests/Internal/MockCloudKit.swift @@ -4,12 +4,12 @@ import CustomDump import SharingGRDBCore final class MockSyncEngine: CKSyncEngineProtocol { - let _engineState: LockIsolated - init(engineState: any CKSyncEngineStateProtocol) { - self._engineState = LockIsolated(engineState) + let _state: LockIsolated + init(state: MockSyncEngineState) { + self._state = LockIsolated(state) } - var engineState: any CKSyncEngineStateProtocol { - _engineState.withValue(\.self) + var state: MockSyncEngineState { + _state.withValue(\.self) } func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { } From 0b8aca8c6d93a54559ccaad3ea498727fe472f4a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 May 2025 11:24:28 -0700 Subject: [PATCH 063/581] wip --- db2.sqlite | Bin 8192 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 db2.sqlite diff --git a/db2.sqlite b/db2.sqlite deleted file mode 100644 index 8ce78514800173bbed886292664a9d118ed494fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI%y9&ZE6b9gP678hKO}d3lx;W_SV3lrNygfk0Cp` z$vJHZ1imd~yG}A^_32hzQ>SM%Am-jeYa$ZO^sNf}+G)PaR{npBph^Dd`(3Q=mOQVI^F5Zsk4xc!Dt EAO3kBrvLx| From 7989f7f377df75515db9489fb565da5e811c21ac Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 May 2025 11:24:55 -0700 Subject: [PATCH 064/581] wip --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9817718f..eee445d9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ xcuserdata/ DerivedData/ .swiftpm .netrc -.sqlite \ No newline at end of file +*.sqlite \ No newline at end of file From 1504cf83efbdcc3fe50e0d63758f0ae2d56c4524 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 May 2025 17:53:14 -0700 Subject: [PATCH 065/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 24 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 51 ++- Tests/SharingGRDBTests/CloudKitTests.swift | 424 ------------------ .../CloudKitTests/BaseCloudKitTests.swift | 38 ++ .../CloudKitTests/CloudKitTests.swift | 185 ++++++++ .../CloudKitTests/ForeignKeyTests.swift | 112 +++++ .../CloudKitTests/Internal.swift | 15 + .../CloudKitTests/Schema.swift | 43 ++ .../CloudKitTests/TriggerTests.swift | 189 ++++++++ 9 files changed, 631 insertions(+), 450 deletions(-) delete mode 100644 Tests/SharingGRDBTests/CloudKitTests.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/BaseCloudKitTests.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/Internal.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/Schema.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 24cdcfcf..3a60e706 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -95,3 +95,27 @@ extension CKRecord: @retroactive CustomDumpReflectable { ) } } +extension CKRecord.ID: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + Mirror( + self, + children: [ + "recordName": recordName, + "zoneID": zoneID + ], + displayStyle: .struct + ) + } +} +extension CKRecordZone.ID: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + Mirror( + self, + children: [ + "zoneName": zoneName, + "ownerName": ownerName + ], + displayStyle: .struct + ) + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ffe8ea18..ee69fe0f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -174,7 +174,7 @@ public final actor SyncEngine { "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" ) .execute(db) - db.add(function: .areTriggersEnabled) + db.add(function: .isUpdatingWithServerRecord) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .willDelete(syncEngine: self)) for table in tables { @@ -202,7 +202,6 @@ public final actor SyncEngine { \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), datetime('subsec') - WHERE areTriggersEnabled() ON CONFLICT("zoneName", "recordName") DO NOTHING; END """ @@ -218,7 +217,6 @@ public final actor SyncEngine { SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name) - WHERE areTriggersEnabled() ON CONFLICT("zoneName", "recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); END @@ -231,8 +229,7 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN DELETE FROM \(Metadata.self) - WHERE areTriggersEnabled() - AND "zoneName" = \(quote: T.tableName, delimiter: .text) + WHERE "zoneName" = \(quote: T.tableName, delimiter: .text) AND "recordName" = "old".\(quote: T.columns.primaryKey.name); END """ @@ -509,7 +506,7 @@ public final actor SyncEngine { } db.remove(function: .willDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) - db.remove(function: .areTriggersEnabled) + db.remove(function: .isUpdatingWithServerRecord) } try database.writeWithoutTransaction { db in try SQLQueryExpression( @@ -802,26 +799,28 @@ extension SyncEngine: CKSyncEngineDelegate { refreshLastKnownServerRecord(modifiedRecord) } - for (recordID, _) in deletions { - if let table = tablesByName[recordID.zoneID.zoneName] { - func open(_: T.Type) { - withErrorReporting(.sharingGRDBCloudKitFailure) { - try database.write { db in - try T.find(recordID: recordID) - .delete() - .execute(db) + $isUpdatingWithServerRecord.withValue(true) { + for (recordID, _) in deletions { + if let table = tablesByName[recordID.zoneID.zoneName] { + func open(_: T.Type) { + withErrorReporting(.sharingGRDBCloudKitFailure) { + try database.write { db in + try T.find(recordID: recordID) + .delete() + .execute(db) + } } } - } - open(table) - } else { - reportIssue( - .sharingGRDBCloudKitFailure.appending( + open(table) + } else { + reportIssue( + .sharingGRDBCloudKitFailure.appending( """ : No table to delete from: "\(recordID.zoneID.zoneName)" """ + ) ) - ) + } } } } @@ -935,7 +934,7 @@ extension SyncEngine: CKSyncEngineDelegate { .joined(separator: ",") ) try database.write { db in - try $areTriggersEnabled.withValue(false) { + try $isUpdatingWithServerRecord.withValue(true) { try SQLQueryExpression(query).execute(db) try Metadata .insert(Metadata(record: record)) { @@ -1004,9 +1003,9 @@ extension DatabaseFunction { } } - fileprivate static var areTriggersEnabled: Self { - Self("areTriggersEnabled", argumentCount: 0) { _ in - SharingGRDBCore.areTriggersEnabled + fileprivate static var isUpdatingWithServerRecord: Self { + Self("isUpdatingWithServerRecord", argumentCount: 0) { _ in + SharingGRDBCore.isUpdatingWithServerRecord } } @@ -1068,7 +1067,7 @@ private struct ForeignKey: QueryDecodable, QueryRepresentable { } } -@TaskLocal private var areTriggersEnabled = true +@TaskLocal private var isUpdatingWithServerRecord = false private struct Trigger { typealias QueryValue = Void @@ -1095,7 +1094,7 @@ private struct Trigger { \(quote: operation == .delete ? "old" : "new").\(quote: Base.columns.primaryKey.name), \(quote: Base.tableName, delimiter: .text) ) - WHERE areTriggersEnabled(); + WHERE NOT isUpdatingWithServerRecord(); END """ } diff --git a/Tests/SharingGRDBTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests.swift deleted file mode 100644 index 03bce39b..00000000 --- a/Tests/SharingGRDBTests/CloudKitTests.swift +++ /dev/null @@ -1,424 +0,0 @@ -import CloudKit -import ConcurrencyExtras -import CustomDump -import InlineSnapshotTesting -import SharingGRDB -import SnapshotTestingCustomDump -import Testing - -@Suite(.snapshots(record: .failed)) -final class CloudKitTests: Sendable { - let database: any DatabaseWriter - let _syncEngine: any Sendable - let underlyingSyncEngine: MockSyncEngine - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - var syncEngine: SyncEngine { - _syncEngine as! SyncEngine - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - let database = try SharingGRDBTests.database() - let underlyingSyncEngine = MockSyncEngine(state: MockSyncEngineState()) - self.database = database - self.underlyingSyncEngine = underlyingSyncEngine - _syncEngine = SyncEngine( - defaultSyncEngine: underlyingSyncEngine, - database: database, - metadatabaseURL: URL.temporaryDirectory.appending( - path: "metadatabase.\(UUID().uuidString).sqlite" - ), - tables: [Reminder.self, RemindersList.self] - ) - try await Task.sleep(for: .seconds(0.1)) - } - - deinit { - underlyingSyncEngine.state.assertPendingDatabaseChanges([]) - underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func setUpAndTearDown() async throws { - var sqls = try await database.write { db in - try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) - } - assertInlineSnapshot(of: sqls, as: .customDump) { - #""" - [ - [0]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_insert_reminders" - AFTER INSERT ON "reminders" FOR EACH ROW BEGIN - SELECT didUpdate( - "new"."id", - 'reminders' - ) - WHERE areTriggersEnabled(); - END - """, - [1]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_update_reminders" - AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - SELECT didUpdate( - "new"."id", - 'reminders' - ) - WHERE areTriggersEnabled(); - END - """, - [2]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_delete_reminders" - BEFORE DELETE ON "reminders" FOR EACH ROW BEGIN - SELECT willDelete( - "old"."id", - 'reminders' - ) - WHERE areTriggersEnabled(); - END - """, - [3]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataInserts" - AFTER INSERT ON "reminders" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName", "userModificationDate") - SELECT - 'reminders', - "new"."id", - datetime('subsec') - WHERE areTriggersEnabled() - ON CONFLICT("zoneName", "recordName") DO NOTHING; - END - """, - [4]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataUpdates" - AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName") - SELECT - 'reminders', - "new"."id" - WHERE areTriggersEnabled() - ON CONFLICT("zoneName", "recordName") DO UPDATE SET - "userModificationDate" = datetime('subsec'); - END - """, - [5]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataDeletes" - AFTER DELETE ON "reminders" FOR EACH ROW BEGIN - DELETE FROM "sharing_grdb_cloudkit_metadata" - WHERE areTriggersEnabled() - AND "zoneName" = 'reminders' - AND "recordName" = "old"."id"; - END - """, - [6]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_remindersLists_onDeleteCascade" - AFTER DELETE ON "remindersLists" - FOR EACH ROW BEGIN - DELETE FROM "reminders" - WHERE "remindersListID" = "old"."id"; - END - """, - [7]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_insert_remindersLists" - AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN - SELECT didUpdate( - "new"."id", - 'remindersLists' - ) - WHERE areTriggersEnabled(); - END - """, - [8]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_update_remindersLists" - AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - SELECT didUpdate( - "new"."id", - 'remindersLists' - ) - WHERE areTriggersEnabled(); - END - """, - [9]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_delete_remindersLists" - BEFORE DELETE ON "remindersLists" FOR EACH ROW BEGIN - SELECT willDelete( - "old"."id", - 'remindersLists' - ) - WHERE areTriggersEnabled(); - END - """, - [10]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataInserts" - AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName", "userModificationDate") - SELECT - 'remindersLists', - "new"."id", - datetime('subsec') - WHERE areTriggersEnabled() - ON CONFLICT("zoneName", "recordName") DO NOTHING; - END - """, - [11]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataUpdates" - AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName") - SELECT - 'remindersLists', - "new"."id" - WHERE areTriggersEnabled() - ON CONFLICT("zoneName", "recordName") DO UPDATE SET - "userModificationDate" = datetime('subsec'); - END - """, - [12]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataDeletes" - AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN - DELETE FROM "sharing_grdb_cloudkit_metadata" - WHERE areTriggersEnabled() - AND "zoneName" = 'remindersLists' - AND "recordName" = "old"."id"; - END - """ - ] - """# - } - - try await syncEngine.tearDownSyncEngine() - sqls = try await database.write { db in - try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) - } - assertInlineSnapshot(of: sqls, as: .customDump) { - """ - [] - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func tearDownAndReSetUp() async throws { - try await syncEngine.tearDownSyncEngine() - try await syncEngine.setUpSyncEngine() - // TODO: it would be nice if `setUpSyncEngine` was async - try await Task.sleep(for: .seconds(0.1)) - - try await database.write { db in - try db.seed { - RemindersList(id: UUID(1), title: "Personal") - } - } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) - ]) - - let record = CKRecord( - recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1), in: RemindersList.self) - ) - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [record], - deletions: [] - ) - expectNoDifference( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), - RemindersList(id: UUID(1), title: "Personal") - ) - - let metadata = - try await database.write { db in - try Metadata.find(recordID: record.recordID).fetchOne(db) - } - #expect(metadata != nil) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func insertUpdateDelete() throws { - try database.write { db in - try RemindersList - .insert(RemindersList(id: UUID(1), title: "Personal")) - .execute(db) - } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) - ]) - try database.write { db in - try RemindersList - .find(UUID(1)) - .update { $0.title = "Work" } - .execute(db) - } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) - ]) - try database.write { db in - try RemindersList - .find(UUID(1)) - .delete() - .execute(db) - } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) - ]) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteCascade() throws { - try database.write { db in - try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(2), title: "Walk", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Haircut", remindersListID: UUID(1)) - } - } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), - .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), - ]) - try database.write { db in - try RemindersList.find(UUID(1)).delete().execute(db) - } - let reminders = try database.read { db in - try Reminder.all.fetchAll(db) - } - #expect(reminders == []) - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), - .deleteRecord(CKRecord.ID(UUID(1), in: Reminder.self)), - .deleteRecord(CKRecord.ID(UUID(2), in: Reminder.self)), - .deleteRecord(CKRecord.ID(UUID(3), in: Reminder.self)), - ]) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteServerRecordUpdate() async throws { - try await database.write { db in - try db.seed { - RemindersList(id: UUID(1), title: "Personal") - } - } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) - ]) - - let record = CKRecord( - recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1), in: RemindersList.self) - ) - // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? - record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString - record.encryptedValues[RemindersList.columns.title.name] = "Work" - record.userModificationDate = Date.distantFuture - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [record], - deletions: [] - ) - expectNoDifference( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), - RemindersList(id: UUID(1), title: "Work") - ) - - let metadata = try #require( - try await database.write { db in - try Metadata.find(recordID: record.recordID).fetchOne(db) - } - ) - // TODO: is there anything else we can assert on? - #expect(metadata.lastKnownServerRecord != nil) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteServerRecordDeleted() async throws { - try await database.write { db in - try db.seed { - RemindersList(id: UUID(1), title: "Personal") - } - } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) - ]) - - let record = CKRecord( - recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1), in: RemindersList.self) - ) - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [], - deletions: [(record.recordID, record.recordType)] - ) - #expect( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() - == 0 - ) - let metadata = try await database.write { db in - try Metadata.find(recordID: record.recordID).fetchOne(db) - } - #expect(metadata == nil) - - // TODO: Do not enqueue a pending zone change when the delete came the server - withKnownIssue { - underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) - } - } -} - -extension CKRecord.ID { - convenience init( - _ id: T.TableColumns.PrimaryKey, - in table: T.Type - ) - where T.TableColumns.PrimaryKey == UUID { - self.init( - recordName: id.uuidString, - zoneID: CKRecordZone.ID(zoneName: T.tableName) - ) - } -} - -@Table private struct Reminder: Equatable, Identifiable { - let id: UUID - var title = "" - var remindersListID: RemindersList.ID -} -@Table private struct RemindersList: Equatable, Identifiable { - let id: UUID - var title = "" -} - -private func database() throws -> DatabasePool { - var configuration = Configuration() - configuration.foreignKeysEnabled = false - let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") - let database = try DatabasePool(path: url.path(), configuration: configuration) - var migrator = DatabaseMigrator() - migrator.registerMigration("Create tables") { db in - try #sql( - """ - CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE - ) STRICT - """ - ) - .execute(db) - } - try migrator.migrate(database) - return database -} diff --git a/Tests/SharingGRDBTests/CloudKitTests/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/BaseCloudKitTests.swift new file mode 100644 index 00000000..46f4ab07 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/BaseCloudKitTests.swift @@ -0,0 +1,38 @@ +import CloudKit +import SharingGRDB +import SnapshotTesting +import Testing + +@Suite(.snapshots(record: .failed)) +class BaseCloudKitTests: @unchecked Sendable { + let database: any DatabaseWriter + let _syncEngine: any Sendable + let underlyingSyncEngine: MockSyncEngine + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var syncEngine: SyncEngine { + _syncEngine as! SyncEngine + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() async throws { + let database = try SharingGRDBTests.database() + let underlyingSyncEngine = MockSyncEngine(state: MockSyncEngineState()) + self.database = database + self.underlyingSyncEngine = underlyingSyncEngine + _syncEngine = SyncEngine( + defaultSyncEngine: underlyingSyncEngine, + database: database, + metadatabaseURL: URL.temporaryDirectory.appending( + path: "metadatabase.\(UUID().uuidString).sqlite" + ), + tables: [Reminder.self, RemindersList.self] + ) + try await Task.sleep(for: .seconds(0.1)) + } + + deinit { + underlyingSyncEngine.state.assertPendingDatabaseChanges([]) + underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift new file mode 100644 index 00000000..d057e8e8 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -0,0 +1,185 @@ +import CloudKit +import ConcurrencyExtras +import CustomDump +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDownAndReSetUp() async throws { + try await syncEngine.tearDownSyncEngine() + try await syncEngine.setUpSyncEngine() + // TODO: it would be nice if `setUpSyncEngine` was async + try await Task.sleep(for: .seconds(0.1)) + + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + ]) + + let record = CKRecord( + recordType: "remindersLists", + recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + ) + await syncEngine.handleFetchedRecordZoneChanges( + modifications: [record], + deletions: [] + ) + expectNoDifference( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + RemindersList(id: UUID(1), title: "Personal") + ) + + let metadata = + try await database.write { db in + try Metadata.find(recordID: record.recordID).fetchOne(db) + } + #expect(metadata != nil) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertUpdateDelete() throws { + try database.write { db in + try RemindersList + .insert(RemindersList(id: UUID(1), title: "Personal")) + .execute(db) + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + ]) + try database.write { db in + try RemindersList + .find(UUID(1)) + .update { $0.title = "Work" } + .execute(db) + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + ]) + try database.write { db in + try RemindersList + .find(UUID(1)) + .delete() + .execute(db) + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + ]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordUpdate() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + ]) + + let record = CKRecord( + recordType: "remindersLists", + recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + ) + let userModificationDate = try #require( + try await database.write { db in + try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db)! + } + ) + + // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? + record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString + record.encryptedValues[RemindersList.columns.title.name] = "Work" + let serverModificationDate = userModificationDate.addingTimeInterval(60) + record.userModificationDate = serverModificationDate + await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) + expectNoDifference( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + RemindersList(id: UUID(1), title: "Work") + ) + + let metadata = try #require( + try await database.write { db in + try Metadata.find(recordID: record.recordID).fetchOne(db) + } + ) + #expect(metadata.userModificationDate == serverModificationDate) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordUpdateWithOldRecord() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + ]) + let record = CKRecord( + recordType: "remindersLists", + recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + ) + let userModificationDate = try #require( + try await database.write { db in + try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db)! + } + ) + + // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? + record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString + record.encryptedValues[RemindersList.columns.title.name] = "Work" + let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) + record.userModificationDate = serverModificationDate + await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) + expectNoDifference( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + RemindersList(id: UUID(1), title: "Personal") + ) + + let metadata = try #require( + try await database.write { db in + try Metadata.find(recordID: record.recordID).fetchOne(db) + } + ) + #expect(metadata.userModificationDate == userModificationDate) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordDeleted() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + ]) + + let record = CKRecord( + recordType: "remindersLists", + recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + ) + await syncEngine.handleFetchedRecordZoneChanges( + modifications: [], + deletions: [(record.recordID, record.recordType)] + ) + #expect( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() + == 0 + ) + let metadata = try await database.write { db in + try Metadata.find(recordID: record.recordID).fetchOne(db) + } + #expect(metadata == nil) + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift new file mode 100644 index 00000000..c9930110 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -0,0 +1,112 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + final class ForeignKeyTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteCascade() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(2), title: "Walk", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Haircut", remindersListID: UUID(1)) + } + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + ]) + try database.write { db in + try RemindersList.find(UUID(1)).delete().execute(db) + } + try database.read { db in + try #expect(Reminder.all.fetchAll(db) == []) + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .deleteRecord(CKRecord.ID(UUID(1), in: Reminder.self)), + .deleteRecord(CKRecord.ID(UUID(2), in: Reminder.self)), + .deleteRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + ]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteSetNull() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(2), title: "Dairy", parentReminderID: UUID(1), remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)) + } + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + ]) + try database.write { db in + try Reminder.find(UUID(1)).delete().execute(db) + } + try database.read { db in + try expectNoDifference( + Reminder.all.fetchAll(db), + [ + Reminder(id: UUID(2), title: "Dairy", parentReminderID: nil, remindersListID: UUID(1)), + Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)), + ] + ) + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .deleteRecord(CKRecord.ID(UUID(1), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), + ]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func updateCascade() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(2), title: "Walk", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Haircut", remindersListID: UUID(1)) + } + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), + .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + ]) + let newID = try database.write { db in + try RemindersList.find(UUID(1)).update { $0.id = UUID() }.returning(\.id).fetchOne(db)! + } + try database.read { db in + try expectNoDifference( + Reminder.all.fetchAll(db), + [ + Reminder(id: UUID(1), title: "Groceries", remindersListID: newID), + Reminder(id: UUID(2), title: "Walk", remindersListID: newID), + Reminder(id: UUID(3), title: "Haircut", remindersListID: newID) + ] + ) + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(newID, in: RemindersList.self)), + .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + ]) + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/Internal.swift b/Tests/SharingGRDBTests/CloudKitTests/Internal.swift new file mode 100644 index 00000000..e7087183 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/Internal.swift @@ -0,0 +1,15 @@ +import CloudKit +import StructuredQueriesCore + +extension CKRecord.ID { + convenience init( + _ id: T.TableColumns.PrimaryKey, + in table: T.Type + ) + where T.TableColumns.PrimaryKey == UUID { + self.init( + recordName: id.uuidString.lowercased(), + zoneID: CKRecordZone.ID(zoneName: T.tableName) + ) + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/Schema.swift b/Tests/SharingGRDBTests/CloudKitTests/Schema.swift new file mode 100644 index 00000000..b57416ed --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/Schema.swift @@ -0,0 +1,43 @@ +import Foundation +import SharingGRDB + +@Table struct Reminder: Equatable, Identifiable { + let id: UUID + var title = "" + var parentReminderID: Reminder.ID? + var remindersListID: RemindersList.ID +} +@Table struct RemindersList: Equatable, Identifiable { + let id: UUID + var title = "" +} + +func database() throws -> DatabasePool { + var configuration = Configuration() + configuration.foreignKeysEnabled = false + let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") + let database = try DatabasePool(path: url.path(), configuration: configuration) + try database.write { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "title" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "title" TEXT NOT NULL, + "parentReminderID" TEXT REFERENCES "reminders"("id") ON DELETE SET NULL, + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """ + ) + .execute(db) + } + return database +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift new file mode 100644 index 00000000..28f8dba6 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -0,0 +1,189 @@ +import CustomDump +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + final class TriggerTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func setUpAndTearDown() async throws { + let triggersAfterSetUp = try await database.write { db in + try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) + } + assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { + #""" + [ + [0]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_insert_reminders" + AFTER INSERT ON "reminders" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'reminders' + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """, + [1]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_update_reminders" + AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'reminders' + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """, + [2]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_delete_reminders" + BEFORE DELETE ON "reminders" FOR EACH ROW BEGIN + SELECT willDelete( + "old"."id", + 'reminders' + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """, + [3]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataInserts" + AFTER INSERT ON "reminders" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName", "userModificationDate") + SELECT + 'reminders', + "new"."id", + datetime('subsec') + ON CONFLICT("zoneName", "recordName") DO NOTHING; + END + """, + [4]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataUpdates" + AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName") + SELECT + 'reminders', + "new"."id" + ON CONFLICT("zoneName", "recordName") DO UPDATE SET + "userModificationDate" = datetime('subsec'); + END + """, + [5]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataDeletes" + AFTER DELETE ON "reminders" FOR EACH ROW BEGIN + DELETE FROM "sharing_grdb_cloudkit_metadata" + WHERE "zoneName" = 'reminders' + AND "recordName" = "old"."id"; + END + """, + [6]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_remindersLists_onDeleteCascade" + AFTER DELETE ON "remindersLists" + FOR EACH ROW BEGIN + DELETE FROM "reminders" + WHERE "remindersListID" = "old"."id"; + END + """, + [7]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_remindersLists_onUpdateCascade" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "remindersListID" = "new"."id" + WHERE "remindersListID" = "old"."id"; + END + """, + [8]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_reminders_onDeleteSetNull" + AFTER DELETE ON "reminders" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "parentReminderID" = NULL + WHERE "parentReminderID" = "old"."id"; + END + """, + [9]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_insert_remindersLists" + AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'remindersLists' + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """, + [10]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_update_remindersLists" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'remindersLists' + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """, + [11]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_delete_remindersLists" + BEFORE DELETE ON "remindersLists" FOR EACH ROW BEGIN + SELECT willDelete( + "old"."id", + 'remindersLists' + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """, + [12]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataInserts" + AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName", "userModificationDate") + SELECT + 'remindersLists', + "new"."id", + datetime('subsec') + ON CONFLICT("zoneName", "recordName") DO NOTHING; + END + """, + [13]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataUpdates" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("zoneName", "recordName") + SELECT + 'remindersLists', + "new"."id" + ON CONFLICT("zoneName", "recordName") DO UPDATE SET + "userModificationDate" = datetime('subsec'); + END + """, + [14]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataDeletes" + AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN + DELETE FROM "sharing_grdb_cloudkit_metadata" + WHERE "zoneName" = 'remindersLists' + AND "recordName" = "old"."id"; + END + """ + ] + """# + } + + try await syncEngine.tearDownSyncEngine() + let triggersAfterTearDown = try await database.write { db in + try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) + } + assertInlineSnapshot(of: triggersAfterTearDown, as: .customDump) { + """ + [] + """ + } + + try await syncEngine.setUpSyncEngine() + try await Task.sleep(for: .seconds(0.1)) + let triggersAfterReSetUp = try await database.write { db in + try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) + } + expectNoDifference(triggersAfterReSetUp, triggersAfterSetUp) + } + } +} From cf37c91d7ecd3b864173458ebfa381a817431b61 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 21 May 2025 09:28:20 -0700 Subject: [PATCH 066/581] wip --- .../CloudKitTests/CloudKitTests.swift | 44 +++++++++++ .../CloudKitTests/Internal.swift | 15 ---- .../CloudKitTests/TriggerTests.swift | 7 ++ .../BaseCloudKitTests.swift | 26 +++++-- ...oudKit.swift => CloudKitTestHelpers.swift} | 77 ++++++++++++++++++- .../{CloudKitTests => Internal}/Schema.swift | 1 + 6 files changed, 149 insertions(+), 21 deletions(-) delete mode 100644 Tests/SharingGRDBTests/CloudKitTests/Internal.swift rename Tests/SharingGRDBTests/{CloudKitTests => Internal}/BaseCloudKitTests.swift (52%) rename Tests/SharingGRDBTests/Internal/{MockCloudKit.swift => CloudKitTestHelpers.swift} (55%) rename Tests/SharingGRDBTests/{CloudKitTests => Internal}/Schema.swift (95%) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index d057e8e8..b89b515e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -8,12 +8,51 @@ import Testing extension BaseCloudKitTests { final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func setUp() throws { + let zones = try database.write { db in + try Zone.all.fetchAll(db) + } + assertInlineSnapshot(of: zones, as: .customDump) { + #""" + [ + [0]: Zone( + zoneName: "remindersLists", + schema: """ + CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "title" TEXT NOT NULL + ) STRICT + """ + ), + [1]: Zone( + zoneName: "reminders", + schema: """ + CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "title" TEXT NOT NULL, + "parentReminderID" TEXT REFERENCES "reminders"("id") ON DELETE SET NULL, + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """ + ) + ] + """# + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDownAndReSetUp() async throws { try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() // TODO: it would be nice if `setUpSyncEngine` was async try await Task.sleep(for: .seconds(0.1)) + underlyingSyncEngine.assertFetchChangesScopes([ + .zoneIDs([ + CKRecordZone.ID(RemindersList.self), + CKRecordZone.ID(Reminder.self), + ]) + ]) try await database.write { db in try db.seed { @@ -44,6 +83,11 @@ extension BaseCloudKitTests { #expect(metadata != nil) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func migration() async throws { + // TODO: how to test what happens after a migration? need to assert that zones are fetched. + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func insertUpdateDelete() throws { try database.write { db in diff --git a/Tests/SharingGRDBTests/CloudKitTests/Internal.swift b/Tests/SharingGRDBTests/CloudKitTests/Internal.swift deleted file mode 100644 index e7087183..00000000 --- a/Tests/SharingGRDBTests/CloudKitTests/Internal.swift +++ /dev/null @@ -1,15 +0,0 @@ -import CloudKit -import StructuredQueriesCore - -extension CKRecord.ID { - convenience init( - _ id: T.TableColumns.PrimaryKey, - in table: T.Type - ) - where T.TableColumns.PrimaryKey == UUID { - self.init( - recordName: id.uuidString.lowercased(), - zoneID: CKRecordZone.ID(zoneName: T.tableName) - ) - } -} diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 28f8dba6..17ad3639 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -1,3 +1,4 @@ +import CloudKit import CustomDump import InlineSnapshotTesting import SharingGRDB @@ -180,6 +181,12 @@ extension BaseCloudKitTests { try await syncEngine.setUpSyncEngine() try await Task.sleep(for: .seconds(0.1)) + underlyingSyncEngine.assertFetchChangesScopes([ + .zoneIDs([ + CKRecordZone.ID(RemindersList.self), + CKRecordZone.ID(Reminder.self), + ]) + ]) let triggersAfterReSetUp = try await database.write { db in try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift similarity index 52% rename from Tests/SharingGRDBTests/CloudKitTests/BaseCloudKitTests.swift rename to Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 46f4ab07..5419c87b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -6,20 +6,25 @@ import Testing @Suite(.snapshots(record: .failed)) class BaseCloudKitTests: @unchecked Sendable { let database: any DatabaseWriter - let _syncEngine: any Sendable - let underlyingSyncEngine: MockSyncEngine + private let _syncEngine: any Sendable + private let _underlyingSyncEngine: any Sendable @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var syncEngine: SyncEngine { _syncEngine as! SyncEngine } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var underlyingSyncEngine: MockSyncEngine { + _underlyingSyncEngine as! MockSyncEngine + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) init() async throws { let database = try SharingGRDBTests.database() let underlyingSyncEngine = MockSyncEngine(state: MockSyncEngineState()) self.database = database - self.underlyingSyncEngine = underlyingSyncEngine + self._underlyingSyncEngine = underlyingSyncEngine _syncEngine = SyncEngine( defaultSyncEngine: underlyingSyncEngine, database: database, @@ -29,10 +34,21 @@ class BaseCloudKitTests: @unchecked Sendable { tables: [Reminder.self, RemindersList.self] ) try await Task.sleep(for: .seconds(0.1)) + underlyingSyncEngine.assertFetchChangesScopes([ + .zoneIDs([ + CKRecordZone.ID(RemindersList.self), + CKRecordZone.ID(Reminder.self), + ]) + ]) } deinit { - underlyingSyncEngine.state.assertPendingDatabaseChanges([]) - underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + underlyingSyncEngine.assertFetchChangesScopes([]) + underlyingSyncEngine.state.assertPendingDatabaseChanges([]) + underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) + } else { + Issue.record("Tests must be run on iOS 17+,m macOS 14+, tvOS 17+ and watchOS 10+.") + } } } diff --git a/Tests/SharingGRDBTests/Internal/MockCloudKit.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift similarity index 55% rename from Tests/SharingGRDBTests/Internal/MockCloudKit.swift rename to Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 7f87e6e1..388ed283 100644 --- a/Tests/SharingGRDBTests/Internal/MockCloudKit.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -3,8 +3,29 @@ import ConcurrencyExtras import CustomDump import SharingGRDBCore +extension CKRecord.ID { + convenience init( + _ id: T.TableColumns.PrimaryKey, + in table: T.Type + ) + where T.TableColumns.PrimaryKey == UUID { + self.init( + recordName: id.uuidString.lowercased(), + zoneID: CKRecordZone.ID(zoneName: T.tableName) + ) + } +} + +extension CKRecordZone.ID { + convenience init(_ table: T.Type) { + self.init(zoneName: T.tableName) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) final class MockSyncEngine: CKSyncEngineProtocol { - let _state: LockIsolated + private let _state: LockIsolated + private let _fetchChangesScopes = LockIsolated>([]) init(state: MockSyncEngineState) { self._state = LockIsolated(state) } @@ -12,9 +33,31 @@ final class MockSyncEngine: CKSyncEngineProtocol { _state.withValue(\.self) } func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { + _ = _fetchChangesScopes.withValue { $0.insert(options.scope) } + } + + func assertFetchChangesScopes( + _ scopes: Set, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _fetchChangesScopes.withValue { + expectNoDifference( + scopes, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) final class MockSyncEngineState: CKSyncEngineStateProtocol { private let _pendingRecordZoneChanges = LockIsolated>([]) private let _pendingDatabaseChanges = LockIsolated>([]) @@ -96,3 +139,35 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol { } } } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKSyncEngine.FetchChangesOptions.Scope: @retroactive Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.all, .all): + return true + case (.allExcluding(let lhs), .allExcluding(let rhs)): + return lhs == rhs + case (.zoneIDs(let lhs), .zoneIDs(let rhs)): + return lhs == rhs + case (.all, _), (.allExcluding, _), (.zoneIDs, _): + return false + @unknown default: + return false + } + } + public func hash(into hasher: inout Hasher) { + switch self { + case .all: + hasher.combine(0) + case .allExcluding(let zoneIDs): + hasher.combine(1) + hasher.combine(zoneIDs) + case .zoneIDs(let zoneIDs): + hasher.combine(2) + hasher.combine(zoneIDs) + @unknown default: + hasher.combine(3) + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift similarity index 95% rename from Tests/SharingGRDBTests/CloudKitTests/Schema.swift rename to Tests/SharingGRDBTests/Internal/Schema.swift index b57416ed..30d97a89 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -12,6 +12,7 @@ import SharingGRDB var title = "" } +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func database() throws -> DatabasePool { var configuration = Configuration() configuration.foreignKeysEnabled = false From ed09ff4099c0e4b51454aa40942eb2591433f037 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 21 May 2025 14:11:50 -0700 Subject: [PATCH 067/581] fixes to serialization state --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ee69fe0f..fa833a65 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -19,7 +19,7 @@ public final actor SyncEngine { let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] var underlyingSyncEngine: (any CKSyncEngineProtocol)! - let defaultSyncEngine: (SyncEngine) -> any CKSyncEngineProtocol + let defaultSyncEngine: (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol public init( container: CKContainer, @@ -28,11 +28,11 @@ public final actor SyncEngine { tables: [any PrimaryKeyedTable.Type] ) { self.init( - defaultSyncEngine: { syncEngine in + defaultSyncEngine: { database, syncEngine in CKSyncEngine( CKSyncEngine.Configuration( database: container.privateCloudDatabase, - stateSerialization: try? database.read { db in + stateSerialization: try? database.read { db in // TODO: write test for this try StateSerialization.all.fetchOne(db)?.data }, delegate: syncEngine @@ -53,7 +53,7 @@ public final actor SyncEngine { tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) { self.init( - defaultSyncEngine: { _ in defaultSyncEngine }, + defaultSyncEngine: { _, _ in defaultSyncEngine }, database: database, logger: Logger(.disabled), metadatabaseURL: metadatabaseURL, @@ -62,7 +62,7 @@ public final actor SyncEngine { } private init( - defaultSyncEngine: @escaping (SyncEngine) -> any CKSyncEngineProtocol, + defaultSyncEngine: @escaping (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol, database: any DatabaseWriter, logger: Logger, metadatabaseURL: URL, @@ -89,7 +89,7 @@ public final actor SyncEngine { } package func setUpSyncEngine() throws { - defer { underlyingSyncEngine = defaultSyncEngine(self) } + defer { underlyingSyncEngine = defaultSyncEngine(metadatabase, self) } metadatabase = try defaultMetadatabase var migrator = DatabaseMigrator() @@ -523,6 +523,7 @@ public final actor SyncEngine { } public func deleteLocalData() throws { + try tearDownSyncEngine() withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in for table in tables { @@ -535,7 +536,6 @@ public final actor SyncEngine { } } } - try tearDownSyncEngine() try setUpSyncEngine() } @@ -763,7 +763,7 @@ extension SyncEngine: CKSyncEngineDelegate { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in try StateSerialization.insert( - StateSerialization(data: event.stateSerialization) + StateSerialization(data: dump(event.stateSerialization, name: "2.StateSerialization")) ) .execute(db) } From 1dfdaddd9ba0103731dafcd750ae11434eba143c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 23 May 2025 08:13:36 -0700 Subject: [PATCH 068/581] revert this later --- Examples/Reminders/RemindersApp.swift | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index e54d8f4d..d7691784 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -4,32 +4,16 @@ import SwiftUI @main struct RemindersApp: App { - @Dependency(\.context) var context - init() { - if context == .live { - try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - $0.defaultSyncEngine = SyncEngine( - container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - Reminder.self, - Tag.self, - ReminderTag.self, - ] - ) - } + try! prepareDependencies { + $0.defaultDatabase = try Reminders.appDatabase() } } var body: some Scene { WindowGroup { - if context == .live { - NavigationStack { - RemindersListsView() - } + NavigationStack { + RemindersListsView() } } } From 475490e95e2bb821c4d4ceccef559adf41d05089 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 26 May 2025 13:53:09 -0700 Subject: [PATCH 069/581] wip --- .../SQLiteQueryDecoder.swift | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift b/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift index 9788588b..8c66318f 100644 --- a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift +++ b/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift @@ -74,24 +74,6 @@ struct SQLiteQueryDecoder: QueryDecoder { guard let uuid = UUID(uuidString: uuidString) else { throw InvalidUUID() } return uuid } - - @inlinable - mutating func decode(_ columnType: Date.Type) throws -> Date? { - try decode(String.self).map { try Date.ISO8601Representation(iso8601String: $0).queryOutput } - } - - @inlinable - mutating func decode(_ columnType: UUID.Type) throws -> UUID? { - guard let uuidString = try decode(String.self) else { return nil } - guard let uuid = UUID(uuidString: uuidString) else { throw InvalidUUID() } - return uuid - } -} - -@usableFromInline -struct InvalidUUID: Error { - @usableFromInline - init() {} } @usableFromInline From 6d62f61d8b73c5f5aa62d079ec86045b6ab6966e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 23 May 2025 10:23:17 -0700 Subject: [PATCH 070/581] wip --- Examples/Reminders/RemindersApp.swift | 12 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 665 +++++++++--------- 2 files changed, 347 insertions(+), 330 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index d7691784..9ae8088f 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -7,6 +7,18 @@ struct RemindersApp: App { init() { try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() + $0.defaultSyncEngine = SyncEngine( + container: CKContainer( + identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" + ), + database: $0.defaultDatabase, + tables: [ + RemindersList.self, + Reminder.self, + Tag.self, + ReminderTag.self, + ] + ) } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index fa833a65..fc4f02e4 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -93,6 +93,7 @@ public final actor SyncEngine { metadatabase = try defaultMetadatabase var migrator = DatabaseMigrator() + // TODO: do we want this? #if DEBUG migrator.eraseDatabaseOnSchemaChange = true #endif @@ -130,7 +131,7 @@ public final actor SyncEngine { } try migrator.migrate(metadatabase) let previousZones = try metadatabase.read { db in - return try Zone.all.fetchAll(db) + try Zone.all.fetchAll(db) } let currentZones = try database.read { db in try SQLQueryExpression( @@ -179,210 +180,7 @@ public final actor SyncEngine { db.add(function: .willDelete(syncEngine: self)) for table in tables { func open(_: T.Type) throws { - try SQLQueryExpression( - Trigger(on: T.self, .after, .insert, select: .didUpdate(syncEngine: self)).create - ) - .execute(db) - try SQLQueryExpression( - Trigger(on: T.self, .after, .update, select: .didUpdate(syncEngine: self)).create - ) - .execute(db) - try SQLQueryExpression( - Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).create - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" - AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN - INSERT INTO \(Metadata.self) - ("zoneName", "recordName", "userModificationDate") - SELECT - \(quote: T.tableName, delimiter: .text), - "new".\(quote: T.columns.primaryKey.name), - datetime('subsec') - ON CONFLICT("zoneName", "recordName") DO NOTHING; - END - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" - AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN - INSERT INTO \(Metadata.self) - ("zoneName", "recordName") - SELECT - \(quote: T.tableName, delimiter: .text), - "new".\(quote: T.columns.primaryKey.name) - ON CONFLICT("zoneName", "recordName") DO UPDATE SET - "userModificationDate" = datetime('subsec'); - END - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" - AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN - DELETE FROM \(Metadata.self) - WHERE "zoneName" = \(quote: T.tableName, delimiter: .text) - AND "recordName" = "old".\(quote: T.columns.primaryKey.name); - END - """ - ) - .execute(db) - - let foreignKeys = try SQLQueryExpression( - """ - SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: T.tableName)) - """, - as: ForeignKey.self - ) - .fetchAll(db) - for foreignKey in foreignKeys { - switch foreignKey.onDelete { - case .cascade: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - DELETE FROM \(table) - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .restrict: - // TODO: Report issue? - continue - - case .setDefault: - let defaultValue = - try SQLQueryExpression( - """ - SELECT "dflt_value" - FROM pragma_table_info(\(bind: T.tableName)) - WHERE "name" = \(bind: foreignKey.from) - """, - as: String?.self - ) - .fetchOne(db) ?? nil - - guard let defaultValue - else { - // TODO: Report issue? - continue - } - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(table) - SET \(quote: foreignKey.from) = \(raw: defaultValue) - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(table) - SET \(quote: foreignKey.from) = NULL - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .noAction: - continue - } - - switch foreignKey.onUpdate { - case .cascade: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" - AFTER UPDATE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(T.self) - SET \(quote: foreignKey.from) = "new".\(quote: foreignKey.to) - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .restrict: - // TODO: Report issue? - continue - - case .setDefault: - let defaultValue = - try SQLQueryExpression( - """ - SELECT "dflt_value" - FROM pragma_table_info(\(bind: T.tableName)) - WHERE "name" = \(bind: foreignKey.from) - """, - as: String?.self - ) - .fetchOne(db) ?? nil - - guard let defaultValue - else { - // TODO: Report issue? - continue - } - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" - AFTER UPDATE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(table) - SET \(quote: foreignKey.from) = \(raw: defaultValue) - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" - AFTER UPDATE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(T.self) - SET \(quote: foreignKey.from) = NULL - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .noAction: - continue - } - } + try createTriggers(table: table, db: db) } try open(table) } @@ -393,114 +191,7 @@ public final actor SyncEngine { try database.write { db in for table in tables { func open(_: T.Type) throws { - let foreignKeys = try SQLQueryExpression( - """ - SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: T.tableName)) - """, - as: ForeignKey.self - ) - .fetchAll(db) - for foreignKey in foreignKeys { - switch foreignKey.onDelete { - case .cascade: - try SQLQueryExpression( - """ - DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" - """ - ) - .execute(db) - - case .restrict: - continue - - case .setDefault: - try SQLQueryExpression( - """ - DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" - """ - ) - .execute(db) - - case .noAction: - continue - } - - switch foreignKey.onUpdate { - case .cascade: - try SQLQueryExpression( - """ - DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" - """ - ) - .execute(db) - - case .restrict: - continue - - case .setDefault: - try SQLQueryExpression( - """ - DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" - """ - ) - .execute(db) - - case .noAction: - continue - } - } - try SQLQueryExpression( - """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" - """ - ) - .execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" - """ - ) - .execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" - """ - ) - .execute(db) - try SQLQueryExpression( - Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).drop - ) - .execute(db) - try SQLQueryExpression( - Trigger(on: T.self, .after, .update, select: .didUpdate(syncEngine: self)).drop - ) - .execute(db) - try SQLQueryExpression( - Trigger(on: T.self, .after, .insert, select: .didUpdate(syncEngine: self)).drop - ) - .execute(db) + try dropTriggers(table: table, db: db) } try open(table) } @@ -589,6 +280,318 @@ public final actor SyncEngine { ) } } + + private func createTriggers(table: T.Type, db: Database) throws { + try Trigger(on: T.self, .after, .insert, select: .didUpdate(syncEngine: self)).create + .execute(db) + try Trigger(on: T.self, .after, .update, select: .didUpdate(syncEngine: self)).create + .execute(db) + try Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).create + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" + AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN + INSERT INTO \(Metadata.self) + ("zoneName", "recordName", "userModificationDate") + SELECT + \(quote: T.tableName, delimiter: .text), + "new".\(quote: T.columns.primaryKey.name), + datetime('subsec') + ON CONFLICT("zoneName", "recordName") DO NOTHING; + END + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" + AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN + INSERT INTO \(Metadata.self) + ("zoneName", "recordName") + SELECT + \(quote: T.tableName, delimiter: .text), + "new".\(quote: T.columns.primaryKey.name) + ON CONFLICT("zoneName", "recordName") DO UPDATE SET + "userModificationDate" = datetime('subsec'); + END + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" + AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN + DELETE FROM \(Metadata.self) + WHERE "zoneName" = \(quote: T.tableName, delimiter: .text) + AND "recordName" = "old".\(quote: T.columns.primaryKey.name); + END + """ + ) + .execute(db) + + let foreignKeys = try SQLQueryExpression( + """ + SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: T.tableName)) + """, + as: ForeignKey.self + ) + .fetchAll(db) + for foreignKey in foreignKeys { + switch foreignKey.onDelete { + case .cascade: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + DELETE FROM \(table) + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + + case .restrict: + // TODO: Report issue? + continue + + case .setDefault: + let defaultValue = + try SQLQueryExpression( + """ + SELECT "dflt_value" + FROM pragma_table_info(\(bind: T.tableName)) + WHERE "name" = \(bind: foreignKey.from) + """, + as: String?.self + ) + .fetchOne(db) ?? nil + + guard let defaultValue + else { + // TODO: Report issue? + continue + } + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(table) + SET \(quote: foreignKey.from) = \(raw: defaultValue) + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" + AFTER DELETE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(table) + SET \(quote: foreignKey.from) = NULL + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + + case .noAction: + continue + } + + switch foreignKey.onUpdate { + case .cascade: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" + AFTER UPDATE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(T.self) + SET \(quote: foreignKey.from) = "new".\(quote: foreignKey.to) + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + + case .restrict: + // TODO: Report issue? + continue + + case .setDefault: + let defaultValue = + try SQLQueryExpression( + """ + SELECT "dflt_value" + FROM pragma_table_info(\(bind: T.tableName)) + WHERE "name" = \(bind: foreignKey.from) + """, + as: String?.self + ) + .fetchOne(db) ?? nil + + guard let defaultValue + else { + // TODO: Report issue? + continue + } + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" + AFTER UPDATE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(table) + SET \(quote: foreignKey.from) = \(raw: defaultValue) + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" + AFTER UPDATE ON \(quote: foreignKey.table) + FOR EACH ROW BEGIN + UPDATE \(T.self) + SET \(quote: foreignKey.from) = NULL + WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); + END + """ + ) + .execute(db) + + case .noAction: + continue + } + } + } + + private func dropTriggers(table: T.Type, db: Database) throws { + let foreignKeys = try SQLQueryExpression( + """ + SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: T.tableName)) + """, + as: ForeignKey.self + ) + .fetchAll(db) + for foreignKey in foreignKeys { + switch foreignKey.onDelete { + case .cascade: + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" + """ + ) + .execute(db) + + case .restrict: + continue + + case .setDefault: + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" + """ + ) + .execute(db) + + case .noAction: + continue + } + + switch foreignKey.onUpdate { + case .cascade: + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" + """ + ) + .execute(db) + + case .restrict: + continue + + case .setDefault: + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + DROP TRIGGER + "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" + """ + ) + .execute(db) + + case .noAction: + continue + } + } + try SQLQueryExpression( + """ + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" + """ + ) + .execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" + """ + ) + .execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" + """ + ) + .execute(db) + try SQLQueryExpression( + Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).drop + ) + .execute(db) + try SQLQueryExpression( + Trigger(on: T.self, .after, .update, select: .didUpdate(syncEngine: self)).drop + ) + .execute(db) + try SQLQueryExpression( + Trigger(on: T.self, .after, .insert, select: .didUpdate(syncEngine: self)).drop + ) + .execute(db) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -815,9 +818,9 @@ extension SyncEngine: CKSyncEngineDelegate { } else { reportIssue( .sharingGRDBCloudKitFailure.appending( - """ - : No table to delete from: "\(recordID.zoneID.zoneName)" - """ + """ + : No table to delete from: "\(recordID.zoneID.zoneName)" + """ ) ) } @@ -1086,17 +1089,19 @@ private struct Trigger { "\(quote: "sharing_grdb_cloudkit_\(operation.rawValue.string.lowercased())_\(Base.tableName)")" } - var create: QueryFragment { - """ - CREATE TEMPORARY TRIGGER \(name) - \(when.rawValue) \(operation.rawValue) ON \(quote: Base.tableName) FOR EACH ROW BEGIN - SELECT \(raw: function.name)( - \(quote: operation == .delete ? "old" : "new").\(quote: Base.columns.primaryKey.name), - \(quote: Base.tableName, delimiter: .text) - ) - WHERE NOT isUpdatingWithServerRecord(); - END - """ + var create: some StructuredQueriesCore.Statement { + SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER \(name) + \(when.rawValue) \(operation.rawValue) ON \(quote: Base.tableName) FOR EACH ROW BEGIN + SELECT \(raw: function.name)( + \(quote: operation == .delete ? "old" : "new").\(quote: Base.columns.primaryKey.name), + \(quote: Base.tableName, delimiter: .text) + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """ + ) } var drop: QueryFragment { @@ -1311,9 +1316,9 @@ extension Logger { .sorted() .joined(separator: ", ") let failedZoneDeletes = - event.failedZoneDeletes.isEmpty - ? "⚪️ No failed deleted zones" - : "🛑 Failed zone delete (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" + event.failedZoneDeletes.isEmpty + ? "⚪️ No failed deleted zones" + : "🛑 Failed zone delete (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" debug( """ From dda3d0c6c016c2318f34d954f25f553791885bd2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 26 May 2025 14:07:21 -0700 Subject: [PATCH 071/581] wip --- Examples/Reminders/RemindersApp.swift | 36 +++++++++++++++------------ 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 9ae8088f..e54d8f4d 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -4,28 +4,32 @@ import SwiftUI @main struct RemindersApp: App { + @Dependency(\.context) var context + init() { - try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - $0.defaultSyncEngine = SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" - ), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - Reminder.self, - Tag.self, - ReminderTag.self, - ] - ) + if context == .live { + try! prepareDependencies { + $0.defaultDatabase = try Reminders.appDatabase() + $0.defaultSyncEngine = SyncEngine( + container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), + database: $0.defaultDatabase, + tables: [ + RemindersList.self, + Reminder.self, + Tag.self, + ReminderTag.self, + ] + ) + } } } var body: some Scene { WindowGroup { - NavigationStack { - RemindersListsView() + if context == .live { + NavigationStack { + RemindersListsView() + } } } } From f2b910f1a5aecd43ded44e4c6f0222efb54452a8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 10:50:49 -0700 Subject: [PATCH 072/581] Renamed Zone to RecordType and associated things. --- .../CloudKit/{Zone.swift => RecordType.swift} | 50 +++++++++---------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 39 +++++++-------- .../CloudKitTests/CloudKitTests.swift | 2 +- 3 files changed, 45 insertions(+), 46 deletions(-) rename Sources/SharingGRDBCore/CloudKit/{Zone.swift => RecordType.swift} (58%) diff --git a/Sources/SharingGRDBCore/CloudKit/Zone.swift b/Sources/SharingGRDBCore/CloudKit/RecordType.swift similarity index 58% rename from Sources/SharingGRDBCore/CloudKit/Zone.swift rename to Sources/SharingGRDBCore/CloudKit/RecordType.swift index e0e9f4cb..8464b89f 100644 --- a/Sources/SharingGRDBCore/CloudKit/Zone.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordType.swift @@ -1,75 +1,75 @@ -// @Table("sharing_grdb_cloudkit_zones") -package struct Zone { +// @Table("sharing_grdb_cloudkit_recordTypes") +package struct RecordType { // @Column(primaryKey: true) - package let zoneName: String + package let tableName: String package let schema: String } // NB: This is generated by inlining the above macro applications. -extension Zone: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { +extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore .PrimaryKeyedTableDefinition { - public typealias QueryValue = Zone - public let zoneName = StructuredQueriesCore.TableColumn( - "zoneName", keyPath: \QueryValue.zoneName) + public typealias QueryValue = RecordType + public let tableName = StructuredQueriesCore.TableColumn( + "tableName", keyPath: \QueryValue.tableName) public let schema = StructuredQueriesCore.TableColumn( "schema", keyPath: \QueryValue.schema) public var primaryKey: StructuredQueriesCore.TableColumn { - self.zoneName + self.tableName } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.zoneName, QueryValue.columns.schema] + [QueryValue.columns.tableName, QueryValue.columns.schema] } } public struct Draft: StructuredQueriesCore.TableDraft { - public typealias PrimaryTable = Zone - let zoneName: String? + public typealias PrimaryTable = RecordType + let tableName: String? let schema: String public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = Zone.Draft - public let zoneName = StructuredQueriesCore.TableColumn( - "zoneName", keyPath: \QueryValue.zoneName) + public typealias QueryValue = RecordType.Draft + public let tableName = StructuredQueriesCore.TableColumn( + "tableName", keyPath: \QueryValue.tableName) public let schema = StructuredQueriesCore.TableColumn( "schema", keyPath: \QueryValue.schema) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.zoneName, QueryValue.columns.schema] + [QueryValue.columns.tableName, QueryValue.columns.schema] } } public static let columns = TableColumns() - public static let tableName = Zone.tableName + public static let tableName = RecordType.tableName public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - self.zoneName = try decoder.decode(String.self) + self.tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) guard let schema else { throw QueryDecodingError.missingRequiredColumn } self.schema = schema } - public init(_ other: Zone) { - self.zoneName = other.zoneName + public init(_ other: RecordType) { + self.tableName = other.tableName self.schema = other.schema } public init( - zoneName: String? = nil, + tableName: String? = nil, schema: String ) { - self.zoneName = zoneName + self.tableName = tableName self.schema = schema } } public static let columns = TableColumns() - public static let tableName = "sharing_grdb_cloudkit_zones" + public static let tableName = "sharing_grdb_cloudkit_recordTypes" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let zoneName = try decoder.decode(String.self) + let tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) - guard let zoneName else { + guard let tableName else { throw QueryDecodingError.missingRequiredColumn } guard let schema else { throw QueryDecodingError.missingRequiredColumn } - self.zoneName = zoneName + self.tableName = tableName self.schema = schema } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index fc4f02e4..1f842930 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -92,6 +92,7 @@ public final actor SyncEngine { defer { underlyingSyncEngine = defaultSyncEngine(metadatabase, self) } metadatabase = try defaultMetadatabase + // TODO: go towards idempotent migrations instead of GRDB migrator by the end of all of this var migrator = DatabaseMigrator() // TODO: do we want this? #if DEBUG @@ -100,7 +101,7 @@ public final actor SyncEngine { migrator.registerMigration("Create Metadata Tables") { db in try SQLQueryExpression( """ - CREATE TABLE "sharing_grdb_cloudkit_metadata" ( + CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_metadata" ( "zoneName" TEXT NOT NULL, "recordName" TEXT NOT NULL, "lastKnownServerRecord" BLOB, @@ -112,8 +113,8 @@ public final actor SyncEngine { .execute(db) try SQLQueryExpression( """ - CREATE TABLE "sharing_grdb_cloudkit_zones" ( - "zoneName" TEXT PRIMARY KEY NOT NULL, + CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_recordTypes" ( + "tableName" TEXT PRIMARY KEY NOT NULL, "schema" TEXT NOT NULL ) STRICT """ @@ -121,7 +122,7 @@ public final actor SyncEngine { .execute(db) try SQLQueryExpression( """ - CREATE TABLE "sharing_grdb_cloudkit_stateSerialization" ( + CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_stateSerialization" ( "id" INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK ("id" = 1), "data" TEXT NOT NULL ) STRICT @@ -130,10 +131,10 @@ public final actor SyncEngine { .execute(db) } try migrator.migrate(metadatabase) - let previousZones = try metadatabase.read { db in - try Zone.all.fetchAll(db) + let previousRecordTypes = try metadatabase.read { db in + try RecordType.all.fetchAll(db) } - let currentZones = try database.read { db in + let currentRecordTypes = try database.read { db in try SQLQueryExpression( """ SELECT "name", "sql" @@ -141,33 +142,31 @@ public final actor SyncEngine { WHERE "type" = 'table' AND "name" IN (\(tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) """, - as: Zone.self + as: RecordType.self ) .fetchAll(db) } - let zonesToFetch = currentZones.filter { currentZone in + let recordTypesToFetch = currentRecordTypes.filter { currentZone in guard - let existingZone = previousZones.first(where: { previousZone in - currentZone.zoneName == previousZone.zoneName + let existingZone = previousRecordTypes.first(where: { previousZone in + currentZone.tableName == previousZone.tableName }) else { return true } return existingZone.schema != currentZone.schema } - if !zonesToFetch.isEmpty { + if !recordTypesToFetch.isEmpty { // TODO: Should we avoid this unstructured task by making 'setUpSyncEngine' async? Task { await withErrorReporting(.sharingGRDBCloudKitFailure) { - try await underlyingSyncEngine.fetchChanges( - CKSyncEngine.FetchChangesOptions( - scope: .zoneIDs(zonesToFetch.map { CKRecordZone(zoneName: $0.zoneName).zoneID }) - ) - ) try await metadatabase.write { db in - for zone in zonesToFetch { - try Zone.upsert(Zone.Draft(zone)).execute(db) + for recordType in recordTypesToFetch { + try RecordType.upsert(RecordType.Draft(recordType)).execute(db) } } } + await withErrorReporting(.sharingGRDBCloudKitFailure) { + try await underlyingSyncEngine.fetchChanges() + } } } try database.write { db in @@ -766,7 +765,7 @@ extension SyncEngine: CKSyncEngineDelegate { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in try StateSerialization.insert( - StateSerialization(data: dump(event.stateSerialization, name: "2.StateSerialization")) + StateSerialization(data: event.stateSerialization) ) .execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index b89b515e..f2f2d5d4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -11,7 +11,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() throws { let zones = try database.write { db in - try Zone.all.fetchAll(db) + try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: zones, as: .customDump) { #""" From 7480c8ce73d874f042c0f9a65b474bb761fa5572 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 10:56:52 -0700 Subject: [PATCH 073/581] Renamed Metadata.zoneName to Metadata.recordType --- .../SharingGRDBCore/CloudKit/Metadata.swift | 16 ++++++------ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 26 +++++++++---------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 737e49bd..9c608dc9 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -3,7 +3,7 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) // @Table("sharing_grdb_cloudkit_metadata") package struct Metadata { - package var zoneName: String + package var recordType: String package var recordName: String // @Column(as: CKRecord?.DataRepresentation.self) package var lastKnownServerRecord: CKRecord? @@ -15,9 +15,9 @@ package struct Metadata { extension Metadata: StructuredQueriesCore.Table { public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Metadata - public let zoneName = StructuredQueriesCore.TableColumn( - "zoneName", - keyPath: \QueryValue.zoneName + public let recordType = StructuredQueriesCore.TableColumn( + "recordType", + keyPath: \QueryValue.recordType ) public let recordName = StructuredQueriesCore.TableColumn( "recordName", @@ -32,7 +32,7 @@ extension Metadata: StructuredQueriesCore.Table { ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [ - QueryValue.columns.zoneName, QueryValue.columns.recordName, + QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.userModificationDate, ] } @@ -40,11 +40,11 @@ extension Metadata: StructuredQueriesCore.Table { public static let columns = TableColumns() public static let tableName = "sharing_grdb_cloudkit_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let zoneName = try decoder.decode(String.self) + let recordType = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) - guard let zoneName else { + guard let recordType else { throw QueryDecodingError.missingRequiredColumn } guard let recordName else { @@ -53,7 +53,7 @@ extension Metadata: StructuredQueriesCore.Table { guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } - self.zoneName = zoneName + self.recordType = recordType self.recordName = recordName self.lastKnownServerRecord = lastKnownServerRecord } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 1f842930..4b5cd2ee 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -102,11 +102,10 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_metadata" ( - "zoneName" TEXT NOT NULL, - "recordName" TEXT NOT NULL, + "recordType" TEXT NOT NULL, + "recordName" TEXT NOT NULL PRIMARY KEY, "lastKnownServerRecord" BLOB, - "userModificationDate" TEXT, - PRIMARY KEY("zoneName", "recordName") + "userModificationDate" TEXT ) STRICT """ ) @@ -114,7 +113,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_recordTypes" ( - "tableName" TEXT PRIMARY KEY NOT NULL, + "tableName" TEXT NOT NULL PRIMARY KEY, "schema" TEXT NOT NULL ) STRICT """ @@ -123,7 +122,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_stateSerialization" ( - "id" INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK ("id" = 1), + "id" INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE CHECK ("id" = 1), "data" TEXT NOT NULL ) STRICT """ @@ -293,12 +292,12 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) - ("zoneName", "recordName", "userModificationDate") + ("recordType", "recordName", "userModificationDate") SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), datetime('subsec') - ON CONFLICT("zoneName", "recordName") DO NOTHING; + ON CONFLICT("recordType", "recordName") DO NOTHING; END """ ) @@ -309,11 +308,11 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) - ("zoneName", "recordName") + ("recordType", "recordName") SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name) - ON CONFLICT("zoneName", "recordName") DO UPDATE SET + ON CONFLICT("recordType", "recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); END """ @@ -325,7 +324,7 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN DELETE FROM \(Metadata.self) - WHERE "zoneName" = \(quote: T.tableName, delimiter: .text) + WHERE "recordType" = \(quote: T.tableName, delimiter: .text) AND "recordName" = "old".\(quote: T.columns.primaryKey.name); END """ @@ -1143,14 +1142,13 @@ private struct Unbindable: Error {} extension Metadata { package static func find(recordID: CKRecord.ID) -> Where { Self.where { - $0.zoneName.eq(recordID.zoneID.zoneName) - && $0.recordName.eq(recordID.recordName) + $0.recordName.eq(recordID.recordName) } } init(record: CKRecord) { self.init( - zoneName: record.recordID.zoneID.zoneName, + recordType: record.recordType, recordName: record.recordID.recordName, lastKnownServerRecord: record, userModificationDate: record.userModificationDate From a3592e604c8eb360bad97d2197be10654ce30120 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 11:51:58 -0700 Subject: [PATCH 074/581] Move everything to default zone. --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 160 +++++++++--------- 1 file changed, 79 insertions(+), 81 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4b5cd2ee..fefa41d8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -12,6 +12,8 @@ extension DependencyValues { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final actor SyncEngine { + public static let defaultZone = CKRecordZone(zoneName: "co.pointfree.SharingGRDB.defaultZone") + let database: any DatabaseWriter let logger: Logger lazy var metadatabase: any DatabaseWriter = try! DatabaseQueue() @@ -145,13 +147,13 @@ public final actor SyncEngine { ) .fetchAll(db) } - let recordTypesToFetch = currentRecordTypes.filter { currentZone in + let recordTypesToFetch = currentRecordTypes.filter { currentRecordType in guard - let existingZone = previousRecordTypes.first(where: { previousZone in - currentZone.tableName == previousZone.tableName + let existingRecordType = previousRecordTypes.first(where: { previousRecordType in + currentRecordType.tableName == previousRecordType.tableName }) else { return true } - return existingZone.schema != currentZone.schema + return existingRecordType.schema != currentRecordType.schema } if !recordTypesToFetch.isEmpty { // TODO: Should we avoid this unstructured task by making 'setUpSyncEngine' async? @@ -297,7 +299,7 @@ public final actor SyncEngine { \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), datetime('subsec') - ON CONFLICT("recordType", "recordName") DO NOTHING; + ON CONFLICT("recordName") DO NOTHING; END """ ) @@ -312,7 +314,7 @@ public final actor SyncEngine { SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name) - ON CONFLICT("recordType", "recordName") DO UPDATE SET + ON CONFLICT("recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); END """ @@ -681,8 +683,12 @@ extension SyncEngine: CKSyncEngineDelegate { } #endif - let metadata = await metadataFor(recordID: recordID) - guard let table = tablesByName[recordID.zoneID.zoneName] + guard let metadata = await metadataFor(recordID: recordID) + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + return nil + } + guard let table = tablesByName[metadata.recordType] else { reportIssue("") syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) @@ -705,14 +711,14 @@ extension SyncEngine: CKSyncEngineDelegate { } let record = - metadata?.lastKnownServerRecord + metadata.lastKnownServerRecord ?? CKRecord( - recordType: recordID.zoneID.zoneName, + recordType: metadata.recordType, recordID: recordID ) record.update( with: T(queryOutput: row), - userModificationDate: metadata?.userModificationDate + userModificationDate: metadata.userModificationDate ) await refreshLastKnownServerRecord(record) sentRecord = recordID @@ -726,10 +732,8 @@ extension SyncEngine: CKSyncEngineDelegate { private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { switch event.changeType { case .signIn: + underlyingSyncEngine.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) for table in tables { - underlyingSyncEngine.state.add( - pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneName: table.tableName))] - ) withErrorReporting(.sharingGRDBCloudKitFailure) { let names: [String] = try database.read { db in func open(_: T.Type) throws -> [String] { @@ -744,7 +748,7 @@ extension SyncEngine: CKSyncEngineDelegate { .saveRecord( CKRecord.ID( recordName: $0, - zoneID: CKRecordZone(zoneName: table.tableName).zoneID + zoneID: Self.defaultZone.zoneID ) ) } @@ -772,37 +776,40 @@ extension SyncEngine: CKSyncEngineDelegate { } private func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { - withErrorReporting(.sharingGRDBCloudKitFailure) { - try database.write { db in - for deletion in event.deletions { - if let table = tablesByName[deletion.zoneID.zoneName] { - func open(_: T.Type) { - withErrorReporting(.sharingGRDBCloudKitFailure) { - try T.delete().execute(db) - } - } - open(table) - } - } - - // TODO: Deal with modifications? - _ = event.modifications - } - } + // TODO: Come back to this once we have zoneName in the metadata table. + // $isUpdatingWithServerRecord.withValue(true) { + // withErrorReporting(.sharingGRDBCloudKitFailure) { + // try database.write { db in + // for deletion in event.deletions { + // if let table = tablesByName[deletion.zoneID.zoneName] { + // func open(_: T.Type) { + // withErrorReporting(.sharingGRDBCloudKitFailure) { + // try T.delete().execute(db) + // } + // } + // open(table) + // } + // } + // + // // TODO: Deal with modifications? + // _ = event.modifications + // } + // } + // } } package func handleFetchedRecordZoneChanges( modifications: [CKRecord], deletions: [(CKRecord.ID, CKRecord.RecordType)] ) { - for modifiedRecord in modifications { - mergeFromServerRecord(modifiedRecord) - refreshLastKnownServerRecord(modifiedRecord) - } - $isUpdatingWithServerRecord.withValue(true) { - for (recordID, _) in deletions { - if let table = tablesByName[recordID.zoneID.zoneName] { + for modifiedRecord in modifications { + mergeFromServerRecord(modifiedRecord) + refreshLastKnownServerRecord(modifiedRecord) + } + + for (recordID, recordType) in deletions { + if let table = tablesByName[recordType] { func open(_: T.Type) { withErrorReporting(.sharingGRDBCloudKitFailure) { try database.write { db in @@ -817,7 +824,7 @@ extension SyncEngine: CKSyncEngineDelegate { reportIssue( .sharingGRDBCloudKitFailure.appending( """ - : No table to delete from: "\(recordID.zoneID.zoneName)" + : No table to delete from: "\(recordType)" """ ) ) @@ -876,6 +883,7 @@ extension SyncEngine: CKSyncEngineDelegate { continue } } + // TODO: handle event.failedRecordDeletes ? look at apple sample code } private func mergeFromServerRecord(_ record: CKRecord) { @@ -885,12 +893,12 @@ extension SyncEngine: CKSyncEngineDelegate { try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db) } ?? nil - guard let table = tablesByName[record.recordID.zoneID.zoneName] + guard let table = tablesByName[record.recordType] else { reportIssue( .sharingGRDBCloudKitFailure.appending( """ - : No table to merge from: "\(record.recordID.zoneID.zoneName)" + : No table to merge from: "\(record.recordType)" """ ) ) @@ -993,14 +1001,21 @@ extension SyncEngine: TestDependencyKey { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { - await syncEngine.didUpdate(recordName: $0, zoneName: $1) + Self("didUpdate") { recordName, _ in + await syncEngine + .didUpdate( + recordName: recordName, + zoneName: SyncEngine.defaultZone.zoneID.zoneName + ) } } fileprivate static func willDelete(syncEngine: SyncEngine) -> Self { - return Self("willDelete") { - await syncEngine.willDelete(recordName: $0, zoneName: $1) + return Self("willDelete") { recordName, _ in + await syncEngine.willDelete( + recordName: recordName, + zoneName: SyncEngine.defaultZone.zoneID.zoneName + ) } } @@ -1068,6 +1083,7 @@ private struct ForeignKey: QueryDecodable, QueryRepresentable { } } +// TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates @TaskLocal private var isUpdatingWithServerRecord = false private struct Trigger { @@ -1250,28 +1266,28 @@ extension Logger { """ ) case .fetchedRecordZoneChanges(let event): - let deletionsByZoneName = Dictionary( + let deletionsByRecordType = Dictionary( grouping: event.deletions, - by: \.recordID.zoneID.zoneName + by: \.recordType ) - let zoneDeletions = deletionsByZoneName.keys.sorted() - .map { zoneName in "\(zoneName) (\(deletionsByZoneName[zoneName]!.count))" } + let recordTypeDeletions = deletionsByRecordType.keys.sorted() + .map { recordType in "\(recordType) (\(deletionsByRecordType[recordType]!.count))" } .joined(separator: ", ") let deletions = event.deletions.isEmpty - ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(zoneDeletions)" + ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(recordTypeDeletions)" - let modificationsByZoneName = Dictionary( + let modificationsByRecordType = Dictionary( grouping: event.modifications, - by: \.record.recordID.zoneID.zoneName + by: \.record.recordType ) - let zoneModifications = modificationsByZoneName.keys.sorted() - .map { zoneName in "\(zoneName) (\(modificationsByZoneName[zoneName]!.count))" } + let recordTypeModifications = modificationsByRecordType.keys.sorted() + .map { recordType in "\(recordType) (\(modificationsByRecordType[recordType]!.count))" } .joined(separator: ", ") let modifications = event.modifications.isEmpty ? "⚪️ No modifications" - : "✅ Records modified (\(event.modifications.count)): \(zoneModifications)" + : "✅ Records modified (\(event.modifications.count)): \(recordTypeModifications)" debug( """ @@ -1327,22 +1343,13 @@ extension Logger { """ ) case .sentRecordZoneChanges(let event): - let savedRecordsByZoneName = Dictionary( + let savedRecordsByRecordType = Dictionary( grouping: event.savedRecords, - by: \.recordID.zoneID.zoneName + by: \.recordType ) - let savedRecords = savedRecordsByZoneName.keys + let savedRecords = savedRecordsByRecordType.keys .sorted() - .map { "\($0) (\(savedRecordsByZoneName[$0]!.count))" } - .joined(separator: ", ") - - let deletedRecordsByZoneName = Dictionary( - grouping: event.deletedRecordIDs, - by: \.zoneID.zoneName - ) - let deletedRecords = deletedRecordsByZoneName.keys - .sorted() - .map { "\($0) (\(deletedRecordsByZoneName[$0]!.count))" } + .map { "\($0) (\(savedRecordsByRecordType[$0]!.count))" } .joined(separator: ", ") let failedRecordSavesByZoneName = Dictionary( @@ -1354,22 +1361,13 @@ extension Logger { .map { "\($0) (\(failedRecordSavesByZoneName[$0]!.count))" } .joined(separator: ", ") - let failedRecordDeletesByZoneName = Dictionary( - grouping: event.failedRecordDeletes.keys, - by: \.zoneID.zoneName - ) - let failedRecordDeletes = failedRecordDeletesByZoneName.keys - .sorted() - .map { "\($0) (\(failedRecordDeletesByZoneName[$0]!.count))" } - .joined(separator: ", ") - debug( """ \(prefix) sentRecordZoneChanges - \(savedRecordsByZoneName.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") - \(deletedRecordsByZoneName.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records: \(deletedRecords)") + \(savedRecordsByRecordType.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") + \(event.deletedRecordIDs.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records (\(event.deletedRecordIDs.count))") \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)") - \(failedRecordDeletesByZoneName.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete: \(failedRecordDeletes)") + \(event.failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(event.failedRecordDeletes.count))") """ ) case .willFetchChanges(let event): From 38db1ee1e7079ccea2d4ca285ee2b50465915d99 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 12:05:02 -0700 Subject: [PATCH 075/581] got tests compiling and passing --- .../CloudKitTests/CloudKitTests.swift | 38 +++++++--------- .../CloudKitTests/ForeignKeyTests.swift | 44 +++++++++---------- .../CloudKitTests/TriggerTests.swift | 27 +++++------- .../Internal/BaseCloudKitTests.swift | 7 +-- .../Internal/CloudKitTestHelpers.swift | 14 +----- 5 files changed, 53 insertions(+), 77 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index f2f2d5d4..83671664 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -16,8 +16,8 @@ extension BaseCloudKitTests { assertInlineSnapshot(of: zones, as: .customDump) { #""" [ - [0]: Zone( - zoneName: "remindersLists", + [0]: RecordType( + tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( "id" TEXT PRIMARY KEY DEFAULT (uuid()), @@ -25,8 +25,8 @@ extension BaseCloudKitTests { ) STRICT """ ), - [1]: Zone( - zoneName: "reminders", + [1]: RecordType( + tableName: "reminders", schema: """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY DEFAULT (uuid()), @@ -47,12 +47,7 @@ extension BaseCloudKitTests { try await syncEngine.setUpSyncEngine() // TODO: it would be nice if `setUpSyncEngine` was async try await Task.sleep(for: .seconds(0.1)) - underlyingSyncEngine.assertFetchChangesScopes([ - .zoneIDs([ - CKRecordZone.ID(RemindersList.self), - CKRecordZone.ID(Reminder.self), - ]) - ]) + underlyingSyncEngine.assertFetchChangesScopes([.all]) try await database.write { db in try db.seed { @@ -60,12 +55,12 @@ extension BaseCloudKitTests { } } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + .saveRecord(CKRecord.ID(UUID(1))) ]) let record = CKRecord( recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + recordID: CKRecord.ID(UUID(1)) ) await syncEngine.handleFetchedRecordZoneChanges( modifications: [record], @@ -96,7 +91,7 @@ extension BaseCloudKitTests { .execute(db) } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + .saveRecord(CKRecord.ID(UUID(1))) ]) try database.write { db in try RemindersList @@ -105,7 +100,7 @@ extension BaseCloudKitTests { .execute(db) } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + .saveRecord(CKRecord.ID(UUID(1))) ]) try database.write { db in try RemindersList @@ -114,7 +109,7 @@ extension BaseCloudKitTests { .execute(db) } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + .deleteRecord(CKRecord.ID(UUID(1))) ]) } @@ -126,12 +121,12 @@ extension BaseCloudKitTests { } } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + .saveRecord(CKRecord.ID(UUID(1))) ]) let record = CKRecord( recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + recordID: CKRecord.ID(UUID(1)) ) let userModificationDate = try #require( try await database.write { db in @@ -155,6 +150,7 @@ extension BaseCloudKitTests { try Metadata.find(recordID: record.recordID).fetchOne(db) } ) + // TODO: Control dates in SQLite in order to get consistent passing on float comparison #expect(metadata.userModificationDate == serverModificationDate) } @@ -166,11 +162,11 @@ extension BaseCloudKitTests { } } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + .saveRecord(CKRecord.ID(UUID(1))) ]) let record = CKRecord( recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + recordID: CKRecord.ID(UUID(1)) ) let userModificationDate = try #require( try await database.write { db in @@ -205,12 +201,12 @@ extension BaseCloudKitTests { } } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)) + .saveRecord(CKRecord.ID(UUID(1))) ]) let record = CKRecord( recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1), in: RemindersList.self) + recordID: CKRecord.ID(UUID(1)) ) await syncEngine.handleFetchedRecordZoneChanges( modifications: [], diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index c9930110..95bdd45c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -19,10 +19,10 @@ extension BaseCloudKitTests { } } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), - .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(2))), + .saveRecord(CKRecord.ID(UUID(3))), ]) try database.write { db in try RemindersList.find(UUID(1)).delete().execute(db) @@ -31,10 +31,10 @@ extension BaseCloudKitTests { try #expect(Reminder.all.fetchAll(db) == []) } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .deleteRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), - .deleteRecord(CKRecord.ID(UUID(1), in: Reminder.self)), - .deleteRecord(CKRecord.ID(UUID(2), in: Reminder.self)), - .deleteRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + .deleteRecord(CKRecord.ID(UUID(1))), + .deleteRecord(CKRecord.ID(UUID(1))), + .deleteRecord(CKRecord.ID(UUID(2))), + .deleteRecord(CKRecord.ID(UUID(3))), ]) } @@ -49,10 +49,10 @@ extension BaseCloudKitTests { } } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), - .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(2))), + .saveRecord(CKRecord.ID(UUID(3))), ]) try database.write { db in try Reminder.find(UUID(1)).delete().execute(db) @@ -67,8 +67,8 @@ extension BaseCloudKitTests { ) } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .deleteRecord(CKRecord.ID(UUID(1), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), + .deleteRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(2))), ]) } @@ -83,10 +83,10 @@ extension BaseCloudKitTests { } } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1), in: RemindersList.self)), - .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(2))), + .saveRecord(CKRecord.ID(UUID(3))), ]) let newID = try database.write { db in try RemindersList.find(UUID(1)).update { $0.id = UUID() }.returning(\.id).fetchOne(db)! @@ -102,10 +102,10 @@ extension BaseCloudKitTests { ) } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(newID, in: RemindersList.self)), - .saveRecord(CKRecord.ID(UUID(1), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(2), in: Reminder.self)), - .saveRecord(CKRecord.ID(UUID(3), in: Reminder.self)), + .saveRecord(CKRecord.ID(newID)), + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(2))), + .saveRecord(CKRecord.ID(UUID(3))), ]) } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 17ad3639..ab8d72c0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -49,23 +49,23 @@ extension BaseCloudKitTests { CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataInserts" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName", "userModificationDate") + ("recordType", "recordName", "userModificationDate") SELECT 'reminders', "new"."id", datetime('subsec') - ON CONFLICT("zoneName", "recordName") DO NOTHING; + ON CONFLICT("recordName") DO NOTHING; END """, [4]: """ CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataUpdates" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName") + ("recordType", "recordName") SELECT 'reminders', "new"."id" - ON CONFLICT("zoneName", "recordName") DO UPDATE SET + ON CONFLICT("recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); END """, @@ -73,7 +73,7 @@ extension BaseCloudKitTests { CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataDeletes" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN DELETE FROM "sharing_grdb_cloudkit_metadata" - WHERE "zoneName" = 'reminders' + WHERE "recordType" = 'reminders' AND "recordName" = "old"."id"; END """, @@ -137,23 +137,23 @@ extension BaseCloudKitTests { CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataInserts" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName", "userModificationDate") + ("recordType", "recordName", "userModificationDate") SELECT 'remindersLists', "new"."id", datetime('subsec') - ON CONFLICT("zoneName", "recordName") DO NOTHING; + ON CONFLICT("recordName") DO NOTHING; END """, [13]: """ CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataUpdates" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" - ("zoneName", "recordName") + ("recordType", "recordName") SELECT 'remindersLists', "new"."id" - ON CONFLICT("zoneName", "recordName") DO UPDATE SET + ON CONFLICT("recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); END """, @@ -161,7 +161,7 @@ extension BaseCloudKitTests { CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataDeletes" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "sharing_grdb_cloudkit_metadata" - WHERE "zoneName" = 'remindersLists' + WHERE "recordType" = 'remindersLists' AND "recordName" = "old"."id"; END """ @@ -181,12 +181,7 @@ extension BaseCloudKitTests { try await syncEngine.setUpSyncEngine() try await Task.sleep(for: .seconds(0.1)) - underlyingSyncEngine.assertFetchChangesScopes([ - .zoneIDs([ - CKRecordZone.ID(RemindersList.self), - CKRecordZone.ID(Reminder.self), - ]) - ]) + underlyingSyncEngine.assertFetchChangesScopes([.all]) let triggersAfterReSetUp = try await database.write { db in try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 5419c87b..51ff878a 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -34,12 +34,7 @@ class BaseCloudKitTests: @unchecked Sendable { tables: [Reminder.self, RemindersList.self] ) try await Task.sleep(for: .seconds(0.1)) - underlyingSyncEngine.assertFetchChangesScopes([ - .zoneIDs([ - CKRecordZone.ID(RemindersList.self), - CKRecordZone.ID(Reminder.self), - ]) - ]) + underlyingSyncEngine.assertFetchChangesScopes([.all]) } deinit { diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 388ed283..aba63170 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -4,24 +4,14 @@ import CustomDump import SharingGRDBCore extension CKRecord.ID { - convenience init( - _ id: T.TableColumns.PrimaryKey, - in table: T.Type - ) - where T.TableColumns.PrimaryKey == UUID { + convenience init(_ id: UUID) { self.init( recordName: id.uuidString.lowercased(), - zoneID: CKRecordZone.ID(zoneName: T.tableName) + zoneID: SyncEngine.defaultZone.zoneID ) } } -extension CKRecordZone.ID { - convenience init(_ table: T.Type) { - self.init(zoneName: T.tableName) - } -} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) final class MockSyncEngine: CKSyncEngineProtocol { private let _state: LockIsolated From 9684f7318d177ad9dd26d92443835967cf49d5cb Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 12:43:12 -0700 Subject: [PATCH 076/581] add zoneName/ownerName to metadata table. --- Sources/SharingGRDB/Exports.swift | 1 + .../SharingGRDBCore/CloudKit/Metadata.swift | 41 +++++++++---------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 21 ++++++++-- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift index 07a5c659..103bfe18 100644 --- a/Sources/SharingGRDB/Exports.swift +++ b/Sources/SharingGRDB/Exports.swift @@ -1,2 +1,3 @@ @_exported import SharingGRDBCore @_exported import StructuredQueriesGRDB + diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 9c608dc9..8216eba7 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -5,36 +5,25 @@ import CloudKit package struct Metadata { package var recordType: String package var recordName: String + package var zoneName: String + package var ownerName: String // @Column(as: CKRecord?.DataRepresentation.self) package var lastKnownServerRecord: CKRecord? package var userModificationDate: Date? } // NB: This is generated by inlining the above macro applications. -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Metadata: StructuredQueriesCore.Table { +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table { public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Metadata - public let recordType = StructuredQueriesCore.TableColumn( - "recordType", - keyPath: \QueryValue.recordType - ) - public let recordName = StructuredQueriesCore.TableColumn( - "recordName", - keyPath: \QueryValue.recordName - ) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord?.DataRepresentation - >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let userModificationDate = StructuredQueriesCore.TableColumn( - "userModificationDate", - keyPath: \QueryValue.userModificationDate - ) + public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) + public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) + public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) + public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [ - QueryValue.columns.recordType, QueryValue.columns.recordName, - QueryValue.columns.lastKnownServerRecord, QueryValue.columns.userModificationDate, - ] + [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.userModificationDate] } } public static let columns = TableColumns() @@ -42,6 +31,8 @@ extension Metadata: StructuredQueriesCore.Table { public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) + let zoneName = try decoder.decode(String.self) + let ownerName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) guard let recordType else { @@ -50,11 +41,19 @@ extension Metadata: StructuredQueriesCore.Table { guard let recordName else { throw QueryDecodingError.missingRequiredColumn } + guard let zoneName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let ownerName else { + throw QueryDecodingError.missingRequiredColumn + } guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } self.recordType = recordType self.recordName = recordName + self.zoneName = zoneName + self.ownerName = ownerName self.lastKnownServerRecord = lastKnownServerRecord } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index fefa41d8..9e618d7d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -12,7 +12,7 @@ extension DependencyValues { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final actor SyncEngine { - public static let defaultZone = CKRecordZone(zoneName: "co.pointfree.SharingGRDB.defaultZone") + public static nonisolated let defaultZone = CKRecordZone(zoneName: "co.pointfree.SharingGRDB.defaultZone") let database: any DatabaseWriter let logger: Logger @@ -106,12 +106,19 @@ public final actor SyncEngine { CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_metadata" ( "recordType" TEXT NOT NULL, "recordName" TEXT NOT NULL PRIMARY KEY, + "zoneName" TEXT NOT NULL, + "ownerName" TEXT NOT NULL, "lastKnownServerRecord" BLOB, "userModificationDate" TEXT ) STRICT """ ) .execute(db) + try SQLQueryExpression(""" + CREATE INDEX IF NOT EXISTS "sharing_grdb_cloudkit_metadata_zoneName_ownerName" + ON "sharing_grdb_cloudkit_metadata" ("zoneName", "ownerName") + """) + .execute(db) try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_recordTypes" ( @@ -294,10 +301,12 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) - ("recordType", "recordName", "userModificationDate") + ("recordType", "recordName", "zoneName", "ownerName", "userModificationDate") SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), + \(quote: Self.defaultZone.zoneID.zoneName, delimiter: .text), + \(quote: Self.defaultZone.zoneID.ownerName, delimiter: .text), datetime('subsec') ON CONFLICT("recordName") DO NOTHING; END @@ -310,10 +319,12 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) - ("recordType", "recordName") + ("recordType", "recordName", "zoneName", "ownerName") SELECT \(quote: T.tableName, delimiter: .text), - "new".\(quote: T.columns.primaryKey.name) + "new".\(quote: T.columns.primaryKey.name), + \(quote: Self.defaultZone.zoneID.zoneName, delimiter: .text), + \(quote: Self.defaultZone.zoneID.ownerName, delimiter: .text) ON CONFLICT("recordName") DO UPDATE SET "userModificationDate" = datetime('subsec'); END @@ -1166,6 +1177,8 @@ extension Metadata { self.init( recordType: record.recordType, recordName: record.recordID.recordName, + zoneName: record.recordID.zoneID.zoneName, + ownerName: record.recordID.zoneID.ownerName, lastKnownServerRecord: record, userModificationDate: record.userModificationDate ) From 73efdfcfe9f3d7743b7f7557ca4873c799be2cab Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 14:07:56 -0700 Subject: [PATCH 077/581] Store parentRecordName in the metadata table --- Examples/Reminders/RemindersApp.swift | 2 +- Sources/SharingGRDB/Exports.swift | 1 - .../SharingGRDBCore/CloudKit/Metadata.swift | 6 ++- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 48 +++++++++++++++---- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index e54d8f4d..632295ba 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -10,7 +10,7 @@ struct RemindersApp: App { if context == .live { try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() - $0.defaultSyncEngine = SyncEngine( + $0.defaultSyncEngine = try SyncEngine( container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), database: $0.defaultDatabase, tables: [ diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift index 103bfe18..07a5c659 100644 --- a/Sources/SharingGRDB/Exports.swift +++ b/Sources/SharingGRDB/Exports.swift @@ -1,3 +1,2 @@ @_exported import SharingGRDBCore @_exported import StructuredQueriesGRDB - diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 8216eba7..549ddd40 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -7,6 +7,7 @@ package struct Metadata { package var recordName: String package var zoneName: String package var ownerName: String + package var parentRecordName: String? // @Column(as: CKRecord?.DataRepresentation.self) package var lastKnownServerRecord: CKRecord? package var userModificationDate: Date? @@ -20,10 +21,11 @@ package struct Metadata { public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) + public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.userModificationDate] + [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.userModificationDate] } } public static let columns = TableColumns() @@ -33,6 +35,7 @@ package struct Metadata { let recordName = try decoder.decode(String.self) let zoneName = try decoder.decode(String.self) let ownerName = try decoder.decode(String.self) + self.parentRecordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) guard let recordType else { @@ -57,3 +60,4 @@ package struct Metadata { self.lastKnownServerRecord = lastKnownServerRecord } } + diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 9e618d7d..5024f50a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -12,7 +12,9 @@ extension DependencyValues { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final actor SyncEngine { - public static nonisolated let defaultZone = CKRecordZone(zoneName: "co.pointfree.SharingGRDB.defaultZone") + public static nonisolated let defaultZone = CKRecordZone( + zoneName: "co.pointfree.SharingGRDB.defaultZone" + ) let database: any DatabaseWriter let logger: Logger @@ -20,6 +22,7 @@ public final actor SyncEngine { private let metadatabaseURL: URL let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + fileprivate let parentKeyByTableName: [String: ForeignKey] var underlyingSyncEngine: (any CKSyncEngineProtocol)! let defaultSyncEngine: (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol @@ -28,8 +31,8 @@ public final actor SyncEngine { database: any DatabaseWriter, logger: Logger = Logger(subsystem: "SharingGRDB", category: "CloudKit"), tables: [any PrimaryKeyedTable.Type] - ) { - self.init( + ) throws { + try self.init( defaultSyncEngine: { database, syncEngine in CKSyncEngine( CKSyncEngine.Configuration( @@ -53,8 +56,8 @@ public final actor SyncEngine { database: any DatabaseWriter, metadatabaseURL: URL, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - ) { - self.init( + ) throws { + try self.init( defaultSyncEngine: { _, _ in defaultSyncEngine }, database: database, logger: Logger(.disabled), @@ -69,7 +72,7 @@ public final actor SyncEngine { logger: Logger, metadatabaseURL: URL, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - ) { + ) throws { // TODO: Explain why / link to documentation? precondition( !database.configuration.foreignKeysEnabled, @@ -83,6 +86,22 @@ public final actor SyncEngine { self.metadatabaseURL = metadatabaseURL self.tables = tables self.tablesByName = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) + self.parentKeyByTableName = Dictionary( + uniqueKeysWithValues: try database.read { db in + try tables.compactMap { table -> (String, ForeignKey)? in + let foreignKeys = try SQLQueryExpression( + """ + SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) + """, + as: ForeignKey.self + ) + .fetchAll(db) + guard foreignKeys.count == 1, let foreignKey = foreignKeys.first + else { return nil } + return (table.tableName, foreignKey) + } + } + ) Task { await withErrorReporting(.sharingGRDBCloudKitFailure) { try await setUpSyncEngine() @@ -108,16 +127,21 @@ public final actor SyncEngine { "recordName" TEXT NOT NULL PRIMARY KEY, "zoneName" TEXT NOT NULL, "ownerName" TEXT NOT NULL, + "parentRecordName" TEXT, "lastKnownServerRecord" BLOB, "userModificationDate" TEXT ) STRICT """ ) .execute(db) - try SQLQueryExpression(""" + // TODO: Should we have "parentRecordName TEXT REFERENCES metadata(recordName) ON DELETE CASCADE" ? + // TODO: Do we ever query for "parentRecordName"? should we add an index? + try SQLQueryExpression( + """ CREATE INDEX IF NOT EXISTS "sharing_grdb_cloudkit_metadata_zoneName_ownerName" ON "sharing_grdb_cloudkit_metadata" ("zoneName", "ownerName") - """) + """ + ) .execute(db) try SQLQueryExpression( """ @@ -295,18 +319,22 @@ public final actor SyncEngine { .execute(db) try Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).create .execute(db) + + let from = parentKeyByTableName[T.tableName]?.from + try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) - ("recordType", "recordName", "zoneName", "ownerName", "userModificationDate") + ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), \(quote: Self.defaultZone.zoneID.zoneName, delimiter: .text), \(quote: Self.defaultZone.zoneID.ownerName, delimiter: .text), + \(raw: from.map { #""new"."\#($0)""# } ?? "NULL"), datetime('subsec') ON CONFLICT("recordName") DO NOTHING; END @@ -1005,7 +1033,7 @@ extension SyncEngine: CKSyncEngineDelegate { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: TestDependencyKey { public static var testValue: SyncEngine { - SyncEngine(container: .default(), database: try! DatabaseQueue(), tables: []) + try! SyncEngine(container: .default(), database: DatabaseQueue(), tables: []) } } From 759384969c6d9c6c8ed4c231afc0bcfdf7ff295f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 14:13:19 -0700 Subject: [PATCH 078/581] update trigger --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5024f50a..0c13af0e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -347,14 +347,16 @@ public final actor SyncEngine { "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) - ("recordType", "recordName", "zoneName", "ownerName") + ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), \(quote: Self.defaultZone.zoneID.zoneName, delimiter: .text), - \(quote: Self.defaultZone.zoneID.ownerName, delimiter: .text) + \(quote: Self.defaultZone.zoneID.ownerName, delimiter: .text), + \(raw: from.map { #""new"."\#($0)""# } ?? "NULL") ON CONFLICT("recordName") DO UPDATE SET - "userModificationDate" = datetime('subsec'); + "userModificationDate" = datetime('subsec'), + "parentRecordName" = "excluded"."parentRecordName"; END """ ) From c2afc393c15e29cb87bec83cedc435c0e72c59af Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 14:25:07 -0700 Subject: [PATCH 079/581] dont delete self --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0c13af0e..f0e3052b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -757,6 +757,15 @@ extension SyncEngine: CKSyncEngineDelegate { recordType: metadata.recordType, recordID: recordID ) + record.parent = metadata.parentRecordName.map { parentRecordName in + CKRecord.Reference( + recordID: CKRecord.ID( + recordName: parentRecordName, + zoneID: record.recordID.zoneID + ), + action: .none + ) + } record.update( with: T(queryOutput: row), userModificationDate: metadata.userModificationDate From fe1a125747a82b0d9cd1e67e3473980df302c529 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 15:05:16 -0700 Subject: [PATCH 080/581] exploring sharing --- Examples/Reminders/Info.plist | 2 + Examples/Reminders/RemindersDetail.swift | 18 ++++++- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 53 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/Examples/Reminders/Info.plist b/Examples/Reminders/Info.plist index ca9a074a..9ef96ef8 100644 --- a/Examples/Reminders/Info.plist +++ b/Examples/Reminders/Info.plist @@ -2,6 +2,8 @@ + CKSharingSupported + UIBackgroundModes remote-notification diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 1439d565..75f005a1 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,4 +1,5 @@ import CasePaths +import CloudKit import SharingGRDB import SwiftUI @@ -11,6 +12,7 @@ struct RemindersDetailView: View { @State var isNewReminderSheetPresented = false @State var isNavigationTitleVisible = false @State var navigationTitleHeight: CGFloat = 36 + @State var isSharePresented = false @Dependency(\.defaultDatabase) private var database @@ -126,6 +128,18 @@ struct RemindersDetailView: View { Image(systemName: "ellipsis.circle") } } + if let remindersList = detailType.list { + ToolbarItem { + Button { + isSharePresented = true + } label: { + Image(systemName: "square.and.arrow.up") + } + .sheet(isPresented: $isSharePresented) { + CloudSharingView(remindersList) + } + } + } } } @@ -140,7 +154,7 @@ struct RemindersDetailView: View { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) $0.position = - rest + rest .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in cases.when(id.element, then: id.offset) } @@ -185,7 +199,7 @@ struct RemindersDetailView: View { fileprivate var remindersQuery: some StructuredQueriesCore.Statement { let query = - Reminder + Reminder .where { if !showCompleted { !$0.isCompleted diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index f0e3052b..2bf1e7df 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -25,6 +25,9 @@ public final actor SyncEngine { fileprivate let parentKeyByTableName: [String: ForeignKey] var underlyingSyncEngine: (any CKSyncEngineProtocol)! let defaultSyncEngine: (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol + let _container: any Sendable + + nonisolated var container: CKContainer { _container as! CKContainer } public init( container: CKContainer, @@ -33,6 +36,7 @@ public final actor SyncEngine { tables: [any PrimaryKeyedTable.Type] ) throws { try self.init( + container: container, defaultSyncEngine: { database, syncEngine in CKSyncEngine( CKSyncEngine.Configuration( @@ -67,6 +71,7 @@ public final actor SyncEngine { } private init( + container: (any Sendable)? = Void?.none, defaultSyncEngine: @escaping (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol, database: any DatabaseWriter, logger: Logger, @@ -80,6 +85,7 @@ public final actor SyncEngine { Foreign key support must be disabled to synchronize with CloudKit. """ ) + self._container = container self.defaultSyncEngine = defaultSyncEngine self.database = database self.logger = logger @@ -1522,3 +1528,50 @@ extension CKSyncEngine: CKSyncEngineProtocol { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine.State: CKSyncEngineStateProtocol { } + + +import UIKit +extension UICloudSharingController { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public convenience init(_ record: T) + where T.TableColumns.PrimaryKey == UUID + { + // TODO: Remove UUID constraint by reaching into metadata table + // TODO: verify that table has no foreign keys + @Dependency(\.defaultSyncEngine) var syncEngine + let record = try! syncEngine.database.write { db in + return try Metadata + .find( + recordID: CKRecord.ID.init( + recordName: record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() + ) + ) + .select(\.lastKnownServerRecord) + .fetchOne(db) + } + self.init( + share: CKShare(rootRecord: record!!), + container: syncEngine.container + ) + } +} + +import SwiftUI + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +public struct CloudSharingView: UIViewControllerRepresentable where T.TableColumns.PrimaryKey == UUID { + let record: T + public init(_ record: T) { + self.record = record + } + + public func makeUIViewController(context: Context) -> UICloudSharingController { + UICloudSharingController(record) + } + + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } +} From a97a2e880a9e4410a39bec7d1edb66e14d2639f8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 15:29:02 -0700 Subject: [PATCH 081/581] wip --- Examples/Examples.xcodeproj/project.pbxproj | 8 ++ Examples/Reminders/RemindersDetail.swift | 107 ++++++++++-------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 70 ++++++++++++ 3 files changed, 139 insertions(+), 46 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 35304b15..43a6acc3 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; + CA5E42502DE7C4D50069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E424F2DE7C4D50069E0F8 /* SwiftUINavigation */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; CAFDD64A2D5E823A00EE099E /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CAFDD6492D5E823A00EE099E /* SharingGRDB */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; @@ -138,6 +139,7 @@ files = ( CAFDD64A2D5E823A00EE099E /* SharingGRDB in Frameworks */, CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, + CA5E42502DE7C4D50069E0F8 /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -281,6 +283,7 @@ packageProductDependencies = ( CAFDD6492D5E823A00EE099E /* SharingGRDB */, CA14DBC82DA884C400E36852 /* CasePaths */, + CA5E424F2DE7C4D50069E0F8 /* SwiftUINavigation */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -919,6 +922,11 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = UIKitNavigation; }; + CA5E424F2DE7C4D50069E0F8 /* SwiftUINavigation */ = { + isa = XCSwiftPackageProductDependency; + package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; + productName = SwiftUINavigation; + }; CAD001862D874F1F00FA977A /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 75f005a1..7b5ffa28 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -2,6 +2,7 @@ import CasePaths import CloudKit import SharingGRDB import SwiftUI +import SwiftUINavigation struct RemindersDetailView: View { @FetchAll private var reminderStates: [ReminderState] @@ -12,7 +13,7 @@ struct RemindersDetailView: View { @State var isNewReminderSheetPresented = false @State var isNavigationTitleVisible = false @State var navigationTitleHeight: CGFloat = 36 - @State var isSharePresented = false + @State var presentedShare: CKShare? @Dependency(\.defaultDatabase) private var database @@ -52,11 +53,11 @@ struct RemindersDetailView: View { move(from: indexSet, to: index) } } - .onScrollGeometryChange(for: Bool.self) { geometry in - geometry.contentOffset.y + geometry.contentInsets.top > navigationTitleHeight - } action: { - isNavigationTitleVisible = $1 - } +// .onScrollGeometryChange(for: Bool.self) { geometry in +// geometry.contentOffset.y + geometry.contentInsets.top > navigationTitleHeight +// } action: { +// isNavigationTitleVisible = $1 +// } .listStyle(.plain) .sheet(isPresented: $isNewReminderSheetPresented) { if let remindersList = detailType.list { @@ -71,14 +72,14 @@ struct RemindersDetailView: View { try await updateQuery() } } - .toolbar { - ToolbarItem(placement: .principal) { - Text(detailType.navigationTitle) - .font(.headline) - .opacity(isNavigationTitleVisible ? 1 : 0) - .animation(.default.speed(2), value: isNavigationTitleVisible) - } - } +// .toolbar { +// ToolbarItem(placement: .principal) { +// Text(detailType.navigationTitle) +// .font(.headline) +// .opacity(isNavigationTitleVisible ? 1 : 0) +// .animation(.default.speed(2), value: isNavigationTitleVisible) +// } +// } .toolbarTitleDisplayMode(.inline) .toolbar { if detailType.is(\.list) { @@ -99,45 +100,59 @@ struct RemindersDetailView: View { .tint(detailType.color) } } - ToolbarItem(placement: .primaryAction) { - Menu { - Group { - Menu { - ForEach(Ordering.allCases, id: \.self) { ordering in - Button { - self.ordering = ordering - } label: { - Text(ordering.rawValue) - ordering.icon - } - } - } label: { - Text("Sort By") - Text(ordering.rawValue) - Image(systemName: "arrow.up.arrow.down") - } - Button { - showCompleted.toggle() - } label: { - Text(showCompleted ? "Hide Completed" : "Show Completed") - Image(systemName: showCompleted ? "eye.slash.fill" : "eye") - } - } - .tint(detailType.color) - } label: { - Image(systemName: "ellipsis.circle") - } - } +// ToolbarItem(placement: .primaryAction) { +// Menu { +// Group { +// Menu { +// ForEach(Ordering.allCases, id: \.self) { ordering in +// Button { +// self.ordering = ordering +// } label: { +// Text(ordering.rawValue) +// ordering.icon +// } +// } +// } label: { +// Text("Sort By") +// Text(ordering.rawValue) +// Image(systemName: "arrow.up.arrow.down") +// } +// Button { +// showCompleted.toggle() +// } label: { +// Text(showCompleted ? "Hide Completed" : "Show Completed") +// Image(systemName: showCompleted ? "eye.slash.fill" : "eye") +// } +// } +// .tint(detailType.color) +// } label: { +// Image(systemName: "ellipsis.circle") +// } +// } if let remindersList = detailType.list { ToolbarItem { Button { - isSharePresented = true + shareButtonTapped(remindersList: remindersList) } label: { Image(systemName: "square.and.arrow.up") } - .sheet(isPresented: $isSharePresented) { - CloudSharingView(remindersList) + .sheet(item: $presentedShare, id: \.self) { share in + CloudSharingView2(share: share) } +// .sheet(isPresented: $isSharePresented) { +// //CloudSharingView(remindersList) +// } + } + } + } + } + + @Dependency(\.defaultSyncEngine) var syncEngine + private func shareButtonTapped(remindersList: RemindersList) { + Task { + await withErrorReporting { + presentedShare = try await syncEngine.share(record: remindersList) { + $0[CKShare.SystemFieldKey.title] = remindersList.title as CKRecordValue } } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 2bf1e7df..fd9722c6 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1529,6 +1529,57 @@ extension CKSyncEngine: CKSyncEngineProtocol { extension CKSyncEngine.State: CKSyncEngineStateProtocol { } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine { + public func share( + record: T, + configure: @Sendable (CKShare) -> Void + ) async throws -> CKShare + where T.TableColumns.PrimaryKey == UUID { + let recordName = record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() + let lastKnownServerRecord = try await database.write { db in + try Metadata + .find(recordID: CKRecord.ID(recordName: recordName)) + .select(\.lastKnownServerRecord) + .fetchOne(db) + } ?? nil + + guard let lastKnownServerRecord + else { + throw NoCKRecordFound() + } + + let shareID = CKRecord.ID( + recordName: UUID().uuidString, + zoneID: lastKnownServerRecord.recordID.zoneID + ) + let share = CKShare.init(rootRecord: lastKnownServerRecord, shareID: shareID) + configure(share) + + let modifyOperation = CKModifyRecordsOperation( + recordsToSave: [share, lastKnownServerRecord], + recordIDsToDelete: nil + ) + try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation) in + modifyOperation.modifyRecordsCompletionBlock = { records, recordIDs, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + + modifyOperation.database = container.privateCloudDatabase + let operationQueue = OperationQueue() + operationQueue.maxConcurrentOperationCount = 1 + operationQueue.addOperation(modifyOperation) + } + + return share + } +} + +struct NoCKRecordFound: Error {} import UIKit extension UICloudSharingController { @@ -1575,3 +1626,22 @@ public struct CloudSharingView: UIViewControllerRepresenta ) { } } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +public struct CloudSharingView2: UIViewControllerRepresentable { + let share: CKShare + public init(share: CKShare) { + self.share = share + } + + public func makeUIViewController(context: Context) -> UICloudSharingController { + @Dependency(\.defaultSyncEngine) var syncEngine + return UICloudSharingController.init(share: share, container: syncEngine.container) + } + + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } +} From 88cca2ee0edafddfd3f6b172f9b38ed80e4e4c0f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 16:02:31 -0700 Subject: [PATCH 082/581] make triggers play nicely with references --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index fd9722c6..d54ad459 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -674,9 +674,13 @@ extension SyncEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let changes = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) + let changes = Array( syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) + .reversed() + ) guard !changes.isEmpty - else { return nil } + else { + return nil + } #if DEBUG struct State { From 0bf95908063374932b4264b3753a15ef3df0f84b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 28 May 2025 16:27:30 -0700 Subject: [PATCH 083/581] test --- Examples/Reminders/Schema.swift | 84 ++++++++++--------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 25 +++++- 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index e079b66d..987ba498 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -251,120 +251,128 @@ let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { func seedSampleData() throws { + let remindersListsIDs = (1...3).map { _ in UUID() } + let remindersIDs = (1...10).map { _ in UUID() } + let tagsIDs = (1...7).map { _ in UUID() } try seed { RemindersList( - id: UUID(0), + id: remindersListsIDs[0], color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), position: 0, title: "Personal" ) RemindersList( - id: UUID(1), + id: remindersListsIDs[1], color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), position: 1, title: "Family" ) RemindersList( - id: UUID(2), + id: remindersListsIDs[2], color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), position: 2, title: "Business" ) Reminder( - id: UUID(0), + id: remindersIDs[0], notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - remindersListID: UUID(0), + remindersListID: remindersListsIDs[0], title: "Groceries" ) Reminder( - id: UUID(1), + id: remindersIDs[1], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isFlagged: true, - remindersListID: UUID(0), + remindersListID: remindersListsIDs[0], title: "Haircut" ) Reminder( - id: UUID(2), + id: remindersIDs[2], dueDate: Date(), notes: "Ask about diet", priority: .high, - remindersListID: UUID(0), + remindersListID: remindersListsIDs[0], title: "Doctor appointment" ) Reminder( - id: UUID(3), + id: remindersIDs[3], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), isCompleted: true, - remindersListID: UUID(0), + remindersListID: remindersListsIDs[0], title: "Take a walk" ) Reminder( - id: UUID(4), + id: remindersIDs[4], dueDate: Date(), - remindersListID: UUID(0), + remindersListID: remindersListsIDs[0], title: "Buy concert tickets" ) Reminder( - id: UUID(5), + id: remindersIDs[5], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), isFlagged: true, priority: .high, - remindersListID: UUID(1), + remindersListID: remindersListsIDs[1], title: "Pick up kids from school" ) Reminder( - id: UUID(6), + id: remindersIDs[6], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .low, - remindersListID: UUID(1), + remindersListID: remindersListsIDs[1], title: "Get laundry" ) Reminder( - id: UUID(7), + id: remindersIDs[7], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), isCompleted: false, priority: .high, - remindersListID: UUID(1), + remindersListID: remindersListsIDs[1], title: "Take out trash" ) Reminder( - id: UUID(8), + id: remindersIDs[8], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), notes: """ Status of tax return Expenses for next year Changing payroll company """, - remindersListID: UUID(2), + remindersListID: remindersListsIDs[2], title: "Call accountant" ) Reminder( - id: UUID(9), + id: remindersIDs[9], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .medium, - remindersListID: UUID(2), + remindersListID: remindersListsIDs[2], title: "Send weekly emails" ) - Tag(id: UUID(0), title: "car") - Tag(id: UUID(1), title: "kids") - Tag(id: UUID(2), title: "someday") - Tag(id: UUID(3), title: "optional") - Tag(id: UUID(4), title: "social") - Tag(id: UUID(5), title: "night") - Tag(id: UUID(6), title: "adulting") + Tag(id: tagsIDs[0], title: "car") + Tag(id: tagsIDs[1], title: "kids") + Tag(id: tagsIDs[2], title: "someday") + Tag(id: tagsIDs[3], title: "optional") + Tag(id: tagsIDs[4], title: "social") + Tag(id: tagsIDs[5], title: "night") + Tag(id: tagsIDs[6], title: "adulting") - ReminderTag(id: UUID(), reminderID: UUID(0), tagID: UUID(2)) - ReminderTag(id: UUID(), reminderID: UUID(0), tagID: UUID(3)) - ReminderTag(id: UUID(), reminderID: UUID(0), tagID: UUID(6)) - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(2)) - ReminderTag(id: UUID(), reminderID: UUID(1), tagID: UUID(3)) - ReminderTag(id: UUID(), reminderID: UUID(2), tagID: UUID(6)) - ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(0)) - ReminderTag(id: UUID(), reminderID: UUID(3), tagID: UUID(1)) + ReminderTag(id: UUID(), reminderID: remindersIDs[0], tagID: tagsIDs[2]) + ReminderTag(id: UUID(), reminderID: remindersIDs[0], tagID: tagsIDs[3]) + ReminderTag(id: UUID(), reminderID: remindersIDs[0], tagID: tagsIDs[6]) + ReminderTag(id: UUID(), reminderID: remindersIDs[1], tagID: tagsIDs[2]) + ReminderTag(id: UUID(), reminderID: remindersIDs[1], tagID: tagsIDs[3]) + ReminderTag(id: UUID(), reminderID: remindersIDs[2], tagID: tagsIDs[6]) + ReminderTag(id: UUID(), reminderID: remindersIDs[3], tagID: tagsIDs[0]) + ReminderTag(id: UUID(), reminderID: remindersIDs[3], tagID: tagsIDs[1]) + ReminderTag(id: UUID(), reminderID: remindersIDs[9], tagID: tagsIDs[3]) + ReminderTag(id: UUID(), reminderID: remindersIDs[8], tagID: tagsIDs[6]) + ReminderTag(id: UUID(), reminderID: remindersIDs[7], tagID: tagsIDs[6]) + ReminderTag(id: UUID(), reminderID: remindersIDs[4], tagID: tagsIDs[4]) + ReminderTag(id: UUID(), reminderID: remindersIDs[4], tagID: tagsIDs[5]) } } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d54ad459..5df6a91a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -674,9 +674,28 @@ extension SyncEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let changes = Array( syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) - .reversed() - ) +// [u,d,u,u,d,d,u,d,u] +// [u,u,u,u,d,d,d,d] + + let allChanges = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) + var allChangesByIsDeleted = Dictionary.init(grouping: allChanges) { + switch $0 { + case .deleteRecord: true + case .saveRecord: false + @unknown default: false + } + } + allChangesByIsDeleted[true]?.reverse() + let changes = allChangesByIsDeleted.reduce(into: []) { changes, keyValue in + changes += keyValue.value + } + +// let changes = Array(syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) +// .sorted { +// switch ($0, $1) { +// case (.saveRecord, .deleteRecord) +// } +// } guard !changes.isEmpty else { return nil From 255a1b43b173c596eabc71892e19039bf4805a9d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 29 May 2025 12:33:11 -0700 Subject: [PATCH 084/581] allow optional foreign keys --- .../CloudKit/CloudKit+StructuredQueries.swift | 1 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 211 +++++++++--------- .../CloudKitTests/CloudKitTests.swift | 18 +- .../CloudKitTests/ForeignKeyTests.swift | 20 +- .../CloudKitTests/TriggerTests.swift | 141 ++++++++++-- .../Internal/BaseCloudKitTests.swift | 6 +- Tests/SharingGRDBTests/Internal/Schema.swift | 23 +- 7 files changed, 279 insertions(+), 141 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 3a60e706..2e6c01ee 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -83,6 +83,7 @@ extension PrimaryKeyedTable { } } +// TODO: Move to custom-dump? @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension CKRecord: @retroactive CustomDumpReflectable { public var customDumpMirror: Mirror { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5df6a91a..b40947c2 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -22,13 +22,11 @@ public final actor SyncEngine { private let metadatabaseURL: URL let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] - fileprivate let parentKeyByTableName: [String: ForeignKey] + fileprivate let foreignKeysByTableName: [String: [ForeignKey]] var underlyingSyncEngine: (any CKSyncEngineProtocol)! let defaultSyncEngine: (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol let _container: any Sendable - nonisolated var container: CKContainer { _container as! CKContainer } - public init( container: CKContainer, database: any DatabaseWriter, @@ -92,19 +90,13 @@ public final actor SyncEngine { self.metadatabaseURL = metadatabaseURL self.tables = tables self.tablesByName = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) - self.parentKeyByTableName = Dictionary( + self.foreignKeysByTableName = Dictionary( uniqueKeysWithValues: try database.read { db in - try tables.compactMap { table -> (String, ForeignKey)? in - let foreignKeys = try SQLQueryExpression( - """ - SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: table.tableName)) - """, - as: ForeignKey.self + try tables.map { table -> (String, [ForeignKey]) in + ( + table.tableName, + try ForeignKey.all(table).fetchAll(db) ) - .fetchAll(db) - guard foreignKeys.count == 1, let foreignKey = foreignKeys.first - else { return nil } - return (table.tableName, foreignKey) } } ) @@ -115,6 +107,10 @@ public final actor SyncEngine { } } + nonisolated var container: CKContainer { + _container as! CKContainer + } + package func setUpSyncEngine() throws { defer { underlyingSyncEngine = defaultSyncEngine(metadatabase, self) } @@ -326,7 +322,10 @@ public final actor SyncEngine { try Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).create .execute(db) - let from = parentKeyByTableName[T.tableName]?.from + let from = + foreignKeysByTableName[T.tableName]?.count(where: \.notnull) == 1 + ? foreignKeysByTableName[T.tableName]?.first(where: \.notnull)?.from + : nil try SQLQueryExpression( """ @@ -380,13 +379,7 @@ public final actor SyncEngine { ) .execute(db) - let foreignKeys = try SQLQueryExpression( - """ - SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: T.tableName)) - """, - as: ForeignKey.self - ) - .fetchAll(db) + let foreignKeys = foreignKeysByTableName[T.tableName] ?? [] for foreignKey in foreignKeys { switch foreignKey.onDelete { case .cascade: @@ -530,13 +523,7 @@ public final actor SyncEngine { } private func dropTriggers(table: T.Type, db: Database) throws { - let foreignKeys = try SQLQueryExpression( - """ - SELECT \(ForeignKey.columns) FROM pragma_foreign_key_list(\(bind: T.tableName)) - """, - as: ForeignKey.self - ) - .fetchAll(db) + let foreignKeys = foreignKeysByTableName[T.tableName] ?? [] for foreignKey in foreignKeys { switch foreignKey.onDelete { case .cascade: @@ -674,11 +661,13 @@ extension SyncEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { -// [u,d,u,u,d,d,u,d,u] -// [u,u,u,u,d,d,d,d] + let allChanges = syncEngine.state.pendingRecordZoneChanges.filter( + context.options.scope.contains + ) + guard !allChanges.isEmpty + else { return nil } - let allChanges = syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) - var allChangesByIsDeleted = Dictionary.init(grouping: allChanges) { + var allChangesByIsDeleted = Dictionary(grouping: allChanges) { switch $0 { case .deleteRecord: true case .saveRecord: false @@ -690,17 +679,6 @@ extension SyncEngine: CKSyncEngineDelegate { changes += keyValue.value } -// let changes = Array(syncEngine.state.pendingRecordZoneChanges.filter(context.options.scope.contains) -// .sorted { -// switch ($0, $1) { -// case (.saveRecord, .deleteRecord) -// } -// } - guard !changes.isEmpty - else { - return nil - } - #if DEBUG struct State { var missingTables: [CKRecord.ID] = [] @@ -1130,6 +1108,21 @@ private struct ForeignKey: QueryDecodable, QueryRepresentable { case noAction = "NO ACTION" } + static func all( + _ table: T.Type + ) -> some StructuredQueriesCore.Statement + { + SQLQueryExpression( + """ + SELECT \(ForeignKey.columns) + FROM pragma_foreign_key_list(\(bind: table.tableName)) AS "foreign_keys" + JOIN pragma_table_info(\(bind: table.tableName)) AS "table_info" + ON "foreign_keys"."from" = "table_info"."name" + """, + as: ForeignKey.self + ) + } + typealias QueryValue = Self let table: String @@ -1137,6 +1130,7 @@ private struct ForeignKey: QueryDecodable, QueryRepresentable { let to: String let onUpdate: Action let onDelete: Action + let notnull: Bool init(decoder: inout some QueryDecoder) throws { guard @@ -1144,7 +1138,8 @@ private struct ForeignKey: QueryDecodable, QueryRepresentable { let from = try decoder.decode(String.self), let to = try decoder.decode(String.self), let onUpdate = try decoder.decode(Action.self), - let onDelete = try decoder.decode(Action.self) + let onDelete = try decoder.decode(Action.self), + let notnull = try decoder.decode(Bool.self) else { throw QueryDecodingError.missingRequiredColumn } @@ -1153,11 +1148,12 @@ private struct ForeignKey: QueryDecodable, QueryRepresentable { self.to = to self.onUpdate = onUpdate self.onDelete = onDelete + self.notnull = notnull } static var columns: QueryFragment { """ - "table", "from", "to", "on_update", "on_delete" + "table", "from", "to", "on_update", "on_delete", "notnull" """ } } @@ -1560,12 +1556,13 @@ extension SyncEngine { ) async throws -> CKShare where T.TableColumns.PrimaryKey == UUID { let recordName = record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() - let lastKnownServerRecord = try await database.write { db in - try Metadata - .find(recordID: CKRecord.ID(recordName: recordName)) - .select(\.lastKnownServerRecord) - .fetchOne(db) - } ?? nil + let lastKnownServerRecord = + try await database.write { db in + try Metadata + .find(recordID: CKRecord.ID(recordName: recordName)) + .select(\.lastKnownServerRecord) + .fetchOne(db) + } ?? nil guard let lastKnownServerRecord else { @@ -1583,7 +1580,8 @@ extension SyncEngine { recordsToSave: [share, lastKnownServerRecord], recordIDsToDelete: nil ) - try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation) in + try await withUnsafeThrowingContinuation { + (continuation: UnsafeContinuation) in modifyOperation.modifyRecordsCompletionBlock = { records, recordIDs, error in if let error = error { continuation.resume(throwing: error) @@ -1604,67 +1602,70 @@ extension SyncEngine { struct NoCKRecordFound: Error {} -import UIKit -extension UICloudSharingController { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public convenience init(_ record: T) - where T.TableColumns.PrimaryKey == UUID - { - // TODO: Remove UUID constraint by reaching into metadata table - // TODO: verify that table has no foreign keys - @Dependency(\.defaultSyncEngine) var syncEngine - let record = try! syncEngine.database.write { db in - return try Metadata - .find( - recordID: CKRecord.ID.init( - recordName: record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() +#if canImport(UIKit) + import UIKit + extension UICloudSharingController { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public convenience init(_ record: T) + where T.TableColumns.PrimaryKey == UUID { + // TODO: Remove UUID constraint by reaching into metadata table + // TODO: verify that table has no foreign keys + @Dependency(\.defaultSyncEngine) var syncEngine + let record = try! syncEngine.database.write { db in + return + try Metadata + .find( + recordID: CKRecord.ID.init( + recordName: record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() + ) ) - ) - .select(\.lastKnownServerRecord) - .fetchOne(db) + .select(\.lastKnownServerRecord) + .fetchOne(db) + } + self.init( + share: CKShare(rootRecord: record!!), + container: syncEngine.container + ) } - self.init( - share: CKShare(rootRecord: record!!), - container: syncEngine.container - ) } -} -import SwiftUI + import SwiftUI -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -public struct CloudSharingView: UIViewControllerRepresentable where T.TableColumns.PrimaryKey == UUID { - let record: T - public init(_ record: T) { - self.record = record - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct CloudSharingView: UIViewControllerRepresentable + where T.TableColumns.PrimaryKey == UUID { + let record: T + public init(_ record: T) { + self.record = record + } - public func makeUIViewController(context: Context) -> UICloudSharingController { - UICloudSharingController(record) - } + public func makeUIViewController(context: Context) -> UICloudSharingController { + UICloudSharingController(record) + } - public func updateUIViewController( - _ uiViewController: UICloudSharingController, - context: Context - ) { + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -public struct CloudSharingView2: UIViewControllerRepresentable { - let share: CKShare - public init(share: CKShare) { - self.share = share - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct CloudSharingView2: UIViewControllerRepresentable { + let share: CKShare + public init(share: CKShare) { + self.share = share + } - public func makeUIViewController(context: Context) -> UICloudSharingController { - @Dependency(\.defaultSyncEngine) var syncEngine - return UICloudSharingController.init(share: share, container: syncEngine.container) - } + public func makeUIViewController(context: Context) -> UICloudSharingController { + @Dependency(\.defaultSyncEngine) var syncEngine + return UICloudSharingController.init(share: share, container: syncEngine.container) + } - public func updateUIViewController( - _ uiViewController: UICloudSharingController, - context: Context - ) { + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } } -} +#endif diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 83671664..8dea9149 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -20,18 +20,28 @@ extension BaseCloudKitTests { tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "title" TEXT NOT NULL ) STRICT """ ), [1]: RecordType( + tableName: "users", + schema: """ + CREATE TABLE "users" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "name" TEXT NOT NULL + ) STRICT + """ + ), + [2]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "assignedUserID" TEXT REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, "title" TEXT NOT NULL, - "parentReminderID" TEXT REFERENCES "reminders"("id") ON DELETE SET NULL, + "parentReminderID" TEXT REFERENCES "reminders"("id") ON DELETE CASCADE ON UPDATE CASCADE, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ @@ -151,7 +161,7 @@ extension BaseCloudKitTests { } ) // TODO: Control dates in SQLite in order to get consistent passing on float comparison - #expect(metadata.userModificationDate == serverModificationDate) + #expect(abs(metadata.userModificationDate!.timeIntervalSince(serverModificationDate)) < 0.1) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 95bdd45c..428ce754 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -42,33 +42,35 @@ extension BaseCloudKitTests { @Test func deleteSetNull() throws { try database.write { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(2), title: "Dairy", parentReminderID: UUID(1), remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)) + User(id: UUID(1), name: "Blob") + RemindersList(id: UUID(2), title: "Personal") + Reminder( + id: UUID(3), + assignedUserID: UUID(1), + title: "Groceries", + remindersListID: UUID(2) + ) } } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(2))), .saveRecord(CKRecord.ID(UUID(3))), ]) try database.write { db in - try Reminder.find(UUID(1)).delete().execute(db) + try User.find(UUID(1)).delete().execute(db) } try database.read { db in try expectNoDifference( Reminder.all.fetchAll(db), [ - Reminder(id: UUID(2), title: "Dairy", parentReminderID: nil, remindersListID: UUID(1)), - Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)), + Reminder(id: UUID(3), assignedUserID: nil, title: "Groceries", remindersListID: UUID(2)), ] ) } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ .deleteRecord(CKRecord.ID(UUID(1))), - .saveRecord(CKRecord.ID(UUID(2))), + .saveRecord(CKRecord.ID(UUID(3))), ]) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index ab8d72c0..d2bf6c03 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -49,10 +49,13 @@ extension BaseCloudKitTests { CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataInserts" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" - ("recordType", "recordName", "userModificationDate") + ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") SELECT 'reminders', "new"."id", + 'co.pointfree.SharingGRDB.defaultZone', + '__defaultOwner__', + "new"."remindersListID", datetime('subsec') ON CONFLICT("recordName") DO NOTHING; END @@ -61,12 +64,16 @@ extension BaseCloudKitTests { CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataUpdates" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" - ("recordType", "recordName") + ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") SELECT 'reminders', - "new"."id" + "new"."id", + 'co.pointfree.SharingGRDB.defaultZone', + '__defaultOwner__', + "new"."remindersListID" ON CONFLICT("recordName") DO UPDATE SET - "userModificationDate" = datetime('subsec'); + "userModificationDate" = datetime('subsec'), + "parentRecordName" = "excluded"."parentRecordName"; END """, [5]: """ @@ -95,15 +102,41 @@ extension BaseCloudKitTests { END """, [8]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_reminders_onDeleteSetNull" + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_reminders_onDeleteCascade" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN - UPDATE "reminders" - SET "parentReminderID" = NULL + DELETE FROM "reminders" WHERE "parentReminderID" = "old"."id"; END """, [9]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_reminders_onUpdateCascade" + AFTER UPDATE ON "reminders" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "parentReminderID" = "new"."id" + WHERE "parentReminderID" = "old"."id"; + END + """, + [10]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_users_onDeleteSetNull" + AFTER DELETE ON "users" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "assignedUserID" = NULL + WHERE "assignedUserID" = "old"."id"; + END + """, + [11]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_users_onUpdateCascade" + AFTER UPDATE ON "users" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "assignedUserID" = "new"."id" + WHERE "assignedUserID" = "old"."id"; + END + """, + [12]: """ CREATE TRIGGER "sharing_grdb_cloudkit_insert_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN SELECT didUpdate( @@ -113,7 +146,7 @@ extension BaseCloudKitTests { WHERE NOT isUpdatingWithServerRecord(); END """, - [10]: """ + [13]: """ CREATE TRIGGER "sharing_grdb_cloudkit_update_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN SELECT didUpdate( @@ -123,7 +156,7 @@ extension BaseCloudKitTests { WHERE NOT isUpdatingWithServerRecord(); END """, - [11]: """ + [14]: """ CREATE TRIGGER "sharing_grdb_cloudkit_delete_remindersLists" BEFORE DELETE ON "remindersLists" FOR EACH ROW BEGIN SELECT willDelete( @@ -133,37 +166,113 @@ extension BaseCloudKitTests { WHERE NOT isUpdatingWithServerRecord(); END """, - [12]: """ + [15]: """ CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataInserts" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" - ("recordType", "recordName", "userModificationDate") + ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") SELECT 'remindersLists', "new"."id", + 'co.pointfree.SharingGRDB.defaultZone', + '__defaultOwner__', + NULL, datetime('subsec') ON CONFLICT("recordName") DO NOTHING; END """, - [13]: """ + [16]: """ CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataUpdates" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sharing_grdb_cloudkit_metadata" - ("recordType", "recordName") + ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") SELECT 'remindersLists', - "new"."id" + "new"."id", + 'co.pointfree.SharingGRDB.defaultZone', + '__defaultOwner__', + NULL ON CONFLICT("recordName") DO UPDATE SET - "userModificationDate" = datetime('subsec'); + "userModificationDate" = datetime('subsec'), + "parentRecordName" = "excluded"."parentRecordName"; END """, - [14]: """ + [17]: """ CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataDeletes" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "sharing_grdb_cloudkit_metadata" WHERE "recordType" = 'remindersLists' AND "recordName" = "old"."id"; END + """, + [18]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_insert_users" + AFTER INSERT ON "users" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'users' + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """, + [19]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_update_users" + AFTER UPDATE ON "users" FOR EACH ROW BEGIN + SELECT didUpdate( + "new"."id", + 'users' + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """, + [20]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_delete_users" + BEFORE DELETE ON "users" FOR EACH ROW BEGIN + SELECT willDelete( + "old"."id", + 'users' + ) + WHERE NOT isUpdatingWithServerRecord(); + END + """, + [21]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_users_metadataInserts" + AFTER INSERT ON "users" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") + SELECT + 'users', + "new"."id", + 'co.pointfree.SharingGRDB.defaultZone', + '__defaultOwner__', + NULL, + datetime('subsec') + ON CONFLICT("recordName") DO NOTHING; + END + """, + [22]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_users_metadataUpdates" + AFTER UPDATE ON "users" FOR EACH ROW BEGIN + INSERT INTO "sharing_grdb_cloudkit_metadata" + ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") + SELECT + 'users', + "new"."id", + 'co.pointfree.SharingGRDB.defaultZone', + '__defaultOwner__', + NULL + ON CONFLICT("recordName") DO UPDATE SET + "userModificationDate" = datetime('subsec'), + "parentRecordName" = "excluded"."parentRecordName"; + END + """, + [23]: """ + CREATE TRIGGER "sharing_grdb_cloudkit_users_metadataDeletes" + AFTER DELETE ON "users" FOR EACH ROW BEGIN + DELETE FROM "sharing_grdb_cloudkit_metadata" + WHERE "recordType" = 'users' + AND "recordName" = "old"."id"; + END """ ] """# diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 51ff878a..0d05eef6 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -3,7 +3,7 @@ import SharingGRDB import SnapshotTesting import Testing -@Suite(.snapshots(record: .failed)) +@Suite(.serialized, .snapshots(record: .failed)) class BaseCloudKitTests: @unchecked Sendable { let database: any DatabaseWriter private let _syncEngine: any Sendable @@ -25,13 +25,13 @@ class BaseCloudKitTests: @unchecked Sendable { let underlyingSyncEngine = MockSyncEngine(state: MockSyncEngineState()) self.database = database self._underlyingSyncEngine = underlyingSyncEngine - _syncEngine = SyncEngine( + _syncEngine = try SyncEngine( defaultSyncEngine: underlyingSyncEngine, database: database, metadatabaseURL: URL.temporaryDirectory.appending( path: "metadatabase.\(UUID().uuidString).sqlite" ), - tables: [Reminder.self, RemindersList.self] + tables: [Reminder.self, RemindersList.self, User.self] ) try await Task.sleep(for: .seconds(0.1)) underlyingSyncEngine.assertFetchChangesScopes([.all]) diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 30d97a89..35e8abb8 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -3,14 +3,19 @@ import SharingGRDB @Table struct Reminder: Equatable, Identifiable { let id: UUID + var assignedUserID: User.ID? var title = "" - var parentReminderID: Reminder.ID? + var parentReminderID: ID? var remindersListID: RemindersList.ID } @Table struct RemindersList: Equatable, Identifiable { let id: UUID var title = "" } +@Table struct User: Equatable, Identifiable { + let id: UUID + var name = "" +} @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func database() throws -> DatabasePool { @@ -22,18 +27,28 @@ func database() throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "title" TEXT NOT NULL ) STRICT """ ) .execute(db) + try #sql( + """ + CREATE TABLE "users" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "name" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) try #sql( """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "assignedUserID" TEXT REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, "title" TEXT NOT NULL, - "parentReminderID" TEXT REFERENCES "reminders"("id") ON DELETE SET NULL, + "parentReminderID" TEXT REFERENCES "reminders"("id") ON DELETE CASCADE ON UPDATE CASCADE, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ From bcabc4dc7fce300a8723ddc2ab3c9246ae2d1ecc Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 29 May 2025 13:15:08 -0700 Subject: [PATCH 085/581] another test --- .../CloudKitTests/CloudKitTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 8dea9149..2eb2e75e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -54,6 +54,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDownAndReSetUp() async throws { try await syncEngine.tearDownSyncEngine() + try await syncEngine.setUpSyncEngine() // TODO: it would be nice if `setUpSyncEngine` was async try await Task.sleep(for: .seconds(0.1)) @@ -88,6 +89,27 @@ extension BaseCloudKitTests { #expect(metadata != nil) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func removeFunctionsOnTearDown() async throws { + try await syncEngine.tearDownSyncEngine() + + for (function, arguments) in [ + ("isUpdatingWithServerRecord", ""), + ("didUpdate", "'test', 'test'"), + ("willUpdate", "'test', 'test'"), + ] { + let error = await #expect(throws: DatabaseError.self) { + try await self.database.write { db in + try #sql("SELECT \(raw: function)(\(raw: arguments))").execute(db) + } + } + #expect( + try #require(error).localizedDescription.contains("no such function: \(function)"), + "Function \(function) was not uninstalled in tear down." + ) + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func migration() async throws { // TODO: how to test what happens after a migration? need to assert that zones are fetched. From eaf85bca1d452c4671c6ee29f6040c92e6b65a78 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 29 May 2025 13:45:59 -0700 Subject: [PATCH 086/581] change prefix and update everything to have prefix --- .../CloudKit/CloudKit+StructuredQueries.swift | 7 +- .../SharingGRDBCore/CloudKit/Metadata.swift | 4 +- .../SharingGRDBCore/CloudKit/RecordType.swift | 4 +- .../CloudKit/StateSerialization.swift | 4 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 60 +++++------ .../CloudKitTests/CloudKitTests.swift | 60 +++++++---- .../CloudKitTests/TriggerTests.swift | 102 +++++++++--------- 7 files changed, 129 insertions(+), 112 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 2e6c01ee..7e99eda4 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -72,7 +72,8 @@ extension CKRecord { set { encryptedValues[Self.userModificationDateKey] = newValue } } - private static let userModificationDateKey = "sharing_grdb_cloudkit_userModificationDate" + private static let userModificationDateKey = + "\(String.sharingGRDBCloudKitSchemaName)_userModificationDate" } extension PrimaryKeyedTable { @@ -102,7 +103,7 @@ extension CKRecord.ID: @retroactive CustomDumpReflectable { self, children: [ "recordName": recordName, - "zoneID": zoneID + "zoneID": zoneID, ], displayStyle: .struct ) @@ -114,7 +115,7 @@ extension CKRecordZone.ID: @retroactive CustomDumpReflectable { self, children: [ "zoneName": zoneName, - "ownerName": ownerName + "ownerName": ownerName, ], displayStyle: .struct ) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 549ddd40..bd92ffd8 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -1,7 +1,7 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Table("sharing_grdb_cloudkit_metadata") +// @Table("\(String.sharingGRDBCloudKitSchemaName)_metadata") package struct Metadata { package var recordType: String package var recordName: String @@ -29,7 +29,7 @@ package struct Metadata { } } public static let columns = TableColumns() - public static let tableName = "sharing_grdb_cloudkit_metadata" + public static let tableName = "\(String.sharingGRDBCloudKitSchemaName)_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) diff --git a/Sources/SharingGRDBCore/CloudKit/RecordType.swift b/Sources/SharingGRDBCore/CloudKit/RecordType.swift index 8464b89f..2c78945e 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordType.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordType.swift @@ -1,4 +1,4 @@ -// @Table("sharing_grdb_cloudkit_recordTypes") +// @Table("\(String.sharingGRDBCloudKitSchemaName)_recordTypes") package struct RecordType { // @Column(primaryKey: true) package let tableName: String @@ -59,7 +59,7 @@ extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.Primary } } public static let columns = TableColumns() - public static let tableName = "sharing_grdb_cloudkit_recordTypes" + public static let tableName = "\(String.sharingGRDBCloudKitSchemaName)_recordTypes" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift index 37906bce..87c11035 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift @@ -1,6 +1,6 @@ import CloudKit -// @Table("sharing_grdb_cloudkit_stateSerialization") +// @Table("\(String.sharingGRDBCloudKitSchemaName)_stateSerialization") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package struct StateSerialization { package var id = 1 @@ -26,7 +26,7 @@ extension StateSerialization: StructuredQueriesCore.Table { } } public static let columns = TableColumns() - public static let tableName = "sharing_grdb_cloudkit_stateSerialization" + public static let tableName = "\(String.sharingGRDBCloudKitSchemaName)_stateSerialization" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { self.id = try decoder.decode(Swift.Int.self) ?? 1 let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b40947c2..39a3b828 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -124,7 +124,7 @@ public final actor SyncEngine { migrator.registerMigration("Create Metadata Tables") { db in try SQLQueryExpression( """ - CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_metadata" ( + CREATE TABLE IF NOT EXISTS "\(raw: .sharingGRDBCloudKitSchemaName)_metadata" ( "recordType" TEXT NOT NULL, "recordName" TEXT NOT NULL PRIMARY KEY, "zoneName" TEXT NOT NULL, @@ -140,14 +140,14 @@ public final actor SyncEngine { // TODO: Do we ever query for "parentRecordName"? should we add an index? try SQLQueryExpression( """ - CREATE INDEX IF NOT EXISTS "sharing_grdb_cloudkit_metadata_zoneName_ownerName" - ON "sharing_grdb_cloudkit_metadata" ("zoneName", "ownerName") + CREATE INDEX IF NOT EXISTS "\(raw: .sharingGRDBCloudKitSchemaName)_metadata_zoneName_ownerName" + ON "\(raw: .sharingGRDBCloudKitSchemaName)_metadata" ("zoneName", "ownerName") """ ) .execute(db) try SQLQueryExpression( """ - CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_recordTypes" ( + CREATE TABLE IF NOT EXISTS "\(raw: .sharingGRDBCloudKitSchemaName)_recordTypes" ( "tableName" TEXT NOT NULL PRIMARY KEY, "schema" TEXT NOT NULL ) STRICT @@ -156,7 +156,7 @@ public final actor SyncEngine { .execute(db) try SQLQueryExpression( """ - CREATE TABLE IF NOT EXISTS "sharing_grdb_cloudkit_stateSerialization" ( + CREATE TABLE IF NOT EXISTS "\(raw: .sharingGRDBCloudKitSchemaName)_stateSerialization" ( "id" INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE CHECK ("id" = 1), "data" TEXT NOT NULL ) STRICT @@ -330,7 +330,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataInserts" AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") @@ -349,7 +349,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataUpdates" AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN INSERT INTO \(Metadata.self) ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") @@ -369,7 +369,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataDeletes" AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN DELETE FROM \(Metadata.self) WHERE "recordType" = \(quote: T.tableName, delimiter: .text) @@ -386,7 +386,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" AFTER DELETE ON \(quote: foreignKey.table) FOR EACH ROW BEGIN DELETE FROM \(table) @@ -420,7 +420,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" AFTER DELETE ON \(quote: foreignKey.table) FOR EACH ROW BEGIN UPDATE \(table) @@ -435,7 +435,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" AFTER DELETE ON \(quote: foreignKey.table) FOR EACH ROW BEGIN UPDATE \(table) @@ -455,7 +455,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" AFTER UPDATE ON \(quote: foreignKey.table) FOR EACH ROW BEGIN UPDATE \(T.self) @@ -490,7 +490,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" AFTER UPDATE ON \(quote: foreignKey.table) FOR EACH ROW BEGIN UPDATE \(table) @@ -505,7 +505,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" AFTER UPDATE ON \(quote: foreignKey.table) FOR EACH ROW BEGIN UPDATE \(T.self) @@ -530,7 +530,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" """ ) .execute(db) @@ -542,7 +542,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" """ ) .execute(db) @@ -551,7 +551,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" """ ) .execute(db) @@ -565,7 +565,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" """ ) .execute(db) @@ -577,7 +577,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" """ ) .execute(db) @@ -586,7 +586,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ DROP TRIGGER - "sharing_grdb_cloudkit_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" + "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" """ ) .execute(db) @@ -597,19 +597,19 @@ public final actor SyncEngine { } try SQLQueryExpression( """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataDeletes" + DROP TRIGGER "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataDeletes" """ ) .execute(db) try SQLQueryExpression( """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataUpdates" + DROP TRIGGER "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataUpdates" """ ) .execute(db) try SQLQueryExpression( """ - DROP TRIGGER "sharing_grdb_cloudkit_\(raw: T.tableName)_metadataInserts" + DROP TRIGGER "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataInserts" """ ) .execute(db) @@ -1077,7 +1077,7 @@ extension DatabaseFunction { } fileprivate static var isUpdatingWithServerRecord: Self { - Self("isUpdatingWithServerRecord", argumentCount: 0) { _ in + Self(.sharingGRDBCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { _ in SharingGRDBCore.isUpdatingWithServerRecord } } @@ -1086,7 +1086,7 @@ extension DatabaseFunction { _ name: String, function: @escaping @Sendable (String, String) async -> Void ) { - self.init(name, argumentCount: 2) { arguments in + self.init(.sharingGRDBCloudKitSchemaName + "_" + name, argumentCount: 2) { arguments in guard let tableName = String.fromDatabaseValue(arguments[0]), let id = String.fromDatabaseValue(arguments[1]) @@ -1175,7 +1175,7 @@ private struct Trigger { } var name: QueryFragment { - "\(quote: "sharing_grdb_cloudkit_\(operation.rawValue.string.lowercased())_\(Base.tableName)")" + "\(quote: "\(String.sharingGRDBCloudKitSchemaName)_\(operation.rawValue.string.lowercased())_\(Base.tableName)")" } var create: some StructuredQueriesCore.Statement { @@ -1187,7 +1187,7 @@ private struct Trigger { \(quote: operation == .delete ? "old" : "new").\(quote: Base.columns.primaryKey.name), \(quote: Base.tableName, delimiter: .text) ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT \(raw: String.sharingGRDBCloudKitSchemaName)_isUpdatingWithServerRecord(); END """ ) @@ -1250,7 +1250,7 @@ extension Metadata { } extension String { - fileprivate static let sharingGRDBCloudKitSchemaName = "sharing_grdb_icloud" + package static let sharingGRDBCloudKitSchemaName = "sqlitedata_icloud" fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" } @@ -1285,13 +1285,13 @@ extension DatabaseWriter where Self == DatabasePool { extension URL { fileprivate static func metadatabase(container: CKContainer) -> Self { applicationSupportDirectory.appending( - component: "\(container.containerIdentifier.map { "\($0)." } ?? "")sharing-grdb-icloud.sqlite" + component: "\(container.containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" ) } } @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -private let logger = Logger(subsystem: "SharingGRDB", category: "CloudKit") +private let logger = Logger(subsystem: "SQLiteData", category: "CloudKit") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Logger { diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 2eb2e75e..b9c24fa7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -83,30 +83,43 @@ extension BaseCloudKitTests { ) let metadata = - try await database.write { db in - try Metadata.find(recordID: record.recordID).fetchOne(db) - } + try await database.write { db in + try Metadata.find(recordID: record.recordID).fetchOne(db) + } #expect(metadata != nil) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func removeFunctionsOnTearDown() async throws { + @Test func addAndRemoveFunctions() async throws { + let query = #sql( + """ + SELECT name + FROM pragma_function_list + WHERE name LIKE \(bind: String.sharingGRDBCloudKitSchemaName + "_%") + """, + as: String.self + ) + assertInlineSnapshot( + of: try { try database.write { try query.fetchAll($0) } }(), + as: .customDump + ) { + """ + [ + [0]: "sqlitedata_icloud_didupdate", + [1]: "sqlitedata_icloud_willdelete", + [2]: "sqlitedata_icloud_isupdatingwithserverrecord" + ] + """ + } try await syncEngine.tearDownSyncEngine() - - for (function, arguments) in [ - ("isUpdatingWithServerRecord", ""), - ("didUpdate", "'test', 'test'"), - ("willUpdate", "'test', 'test'"), - ] { - let error = await #expect(throws: DatabaseError.self) { - try await self.database.write { db in - try #sql("SELECT \(raw: function)(\(raw: arguments))").execute(db) - } - } - #expect( - try #require(error).localizedDescription.contains("no such function: \(function)"), - "Function \(function) was not uninstalled in tear down." - ) + + assertInlineSnapshot( + of: try { try database.write { try query.fetchAll($0) } }(), + as: .customDump + ) { + """ + [] + """ } } @@ -162,7 +175,7 @@ extension BaseCloudKitTests { ) let userModificationDate = try #require( try await database.write { db in - try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db)! + try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db) ?? nil } ) @@ -202,7 +215,10 @@ extension BaseCloudKitTests { ) let userModificationDate = try #require( try await database.write { db in - try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db)! + try Metadata + .find(recordID: record.recordID) + .select(\.userModificationDate) + .fetchOne(db) ?? nil } ) @@ -246,7 +262,7 @@ extension BaseCloudKitTests { ) #expect( try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() - == 0 + == 0 ) let metadata = try await database.write { db in try Metadata.find(recordID: record.recordID).fetchOne(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index d2bf6c03..981fb009 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -16,39 +16,39 @@ extension BaseCloudKitTests { #""" [ [0]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_insert_reminders" + CREATE TRIGGER "sqlitedata_icloud_insert_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN - SELECT didUpdate( + SELECT sqlitedata_icloud_didUpdate( "new"."id", 'reminders' ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [1]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_update_reminders" + CREATE TRIGGER "sqlitedata_icloud_update_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - SELECT didUpdate( + SELECT sqlitedata_icloud_didUpdate( "new"."id", 'reminders' ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [2]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_delete_reminders" + CREATE TRIGGER "sqlitedata_icloud_delete_reminders" BEFORE DELETE ON "reminders" FOR EACH ROW BEGIN - SELECT willDelete( + SELECT sqlitedata_icloud_willDelete( "old"."id", 'reminders' ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [3]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataInserts" + CREATE TRIGGER "sqlitedata_icloud_reminders_metadataInserts" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" + INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") SELECT 'reminders', @@ -61,9 +61,9 @@ extension BaseCloudKitTests { END """, [4]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataUpdates" + CREATE TRIGGER "sqlitedata_icloud_reminders_metadataUpdates" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" + INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") SELECT 'reminders', @@ -77,15 +77,15 @@ extension BaseCloudKitTests { END """, [5]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_metadataDeletes" + CREATE TRIGGER "sqlitedata_icloud_reminders_metadataDeletes" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN - DELETE FROM "sharing_grdb_cloudkit_metadata" + DELETE FROM "sqlitedata_icloud_metadata" WHERE "recordType" = 'reminders' AND "recordName" = "old"."id"; END """, [6]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_remindersLists_onDeleteCascade" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "reminders" @@ -93,7 +93,7 @@ extension BaseCloudKitTests { END """, [7]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_remindersLists_onUpdateCascade" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN UPDATE "reminders" @@ -102,7 +102,7 @@ extension BaseCloudKitTests { END """, [8]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_reminders_onDeleteCascade" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onDeleteCascade" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN DELETE FROM "reminders" @@ -110,7 +110,7 @@ extension BaseCloudKitTests { END """, [9]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_reminders_onUpdateCascade" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onUpdateCascade" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN UPDATE "reminders" @@ -119,7 +119,7 @@ extension BaseCloudKitTests { END """, [10]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_users_onDeleteSetNull" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_users_onDeleteSetNull" AFTER DELETE ON "users" FOR EACH ROW BEGIN UPDATE "reminders" @@ -128,7 +128,7 @@ extension BaseCloudKitTests { END """, [11]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_reminders_belongsTo_users_onUpdateCascade" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_users_onUpdateCascade" AFTER UPDATE ON "users" FOR EACH ROW BEGIN UPDATE "reminders" @@ -137,39 +137,39 @@ extension BaseCloudKitTests { END """, [12]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_insert_remindersLists" + CREATE TRIGGER "sqlitedata_icloud_insert_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN - SELECT didUpdate( + SELECT sqlitedata_icloud_didUpdate( "new"."id", 'remindersLists' ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [13]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_update_remindersLists" + CREATE TRIGGER "sqlitedata_icloud_update_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - SELECT didUpdate( + SELECT sqlitedata_icloud_didUpdate( "new"."id", 'remindersLists' ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [14]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_delete_remindersLists" + CREATE TRIGGER "sqlitedata_icloud_delete_remindersLists" BEFORE DELETE ON "remindersLists" FOR EACH ROW BEGIN - SELECT willDelete( + SELECT sqlitedata_icloud_willDelete( "old"."id", 'remindersLists' ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [15]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataInserts" + CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataInserts" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" + INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") SELECT 'remindersLists', @@ -182,9 +182,9 @@ extension BaseCloudKitTests { END """, [16]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataUpdates" + CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataUpdates" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" + INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") SELECT 'remindersLists', @@ -198,47 +198,47 @@ extension BaseCloudKitTests { END """, [17]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_remindersLists_metadataDeletes" + CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataDeletes" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN - DELETE FROM "sharing_grdb_cloudkit_metadata" + DELETE FROM "sqlitedata_icloud_metadata" WHERE "recordType" = 'remindersLists' AND "recordName" = "old"."id"; END """, [18]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_insert_users" + CREATE TRIGGER "sqlitedata_icloud_insert_users" AFTER INSERT ON "users" FOR EACH ROW BEGIN - SELECT didUpdate( + SELECT sqlitedata_icloud_didUpdate( "new"."id", 'users' ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [19]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_update_users" + CREATE TRIGGER "sqlitedata_icloud_update_users" AFTER UPDATE ON "users" FOR EACH ROW BEGIN - SELECT didUpdate( + SELECT sqlitedata_icloud_didUpdate( "new"."id", 'users' ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [20]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_delete_users" + CREATE TRIGGER "sqlitedata_icloud_delete_users" BEFORE DELETE ON "users" FOR EACH ROW BEGIN - SELECT willDelete( + SELECT sqlitedata_icloud_willDelete( "old"."id", 'users' ) - WHERE NOT isUpdatingWithServerRecord(); + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [21]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_users_metadataInserts" + CREATE TRIGGER "sqlitedata_icloud_users_metadataInserts" AFTER INSERT ON "users" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" + INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") SELECT 'users', @@ -251,9 +251,9 @@ extension BaseCloudKitTests { END """, [22]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_users_metadataUpdates" + CREATE TRIGGER "sqlitedata_icloud_users_metadataUpdates" AFTER UPDATE ON "users" FOR EACH ROW BEGIN - INSERT INTO "sharing_grdb_cloudkit_metadata" + INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") SELECT 'users', @@ -267,9 +267,9 @@ extension BaseCloudKitTests { END """, [23]: """ - CREATE TRIGGER "sharing_grdb_cloudkit_users_metadataDeletes" + CREATE TRIGGER "sqlitedata_icloud_users_metadataDeletes" AFTER DELETE ON "users" FOR EACH ROW BEGIN - DELETE FROM "sharing_grdb_cloudkit_metadata" + DELETE FROM "sqlitedata_icloud_metadata" WHERE "recordType" = 'users' AND "recordName" = "old"."id"; END From 91854f83774008398f9b00f0bf9fca3435378288 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 29 May 2025 14:29:56 -0700 Subject: [PATCH 087/581] shared database --- Examples/Reminders/RemindersApp.swift | 37 +++++++++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 52 +++++++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 632295ba..ddc15078 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -4,6 +4,7 @@ import SwiftUI @main struct RemindersApp: App { + @UIApplicationDelegateAdaptor var delegate: AppDelegate @Dependency(\.context) var context init() { @@ -34,3 +35,39 @@ struct RemindersApp: App { } } } + + +import UIKit + +class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + func windowScene( + _ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + @Dependency(\.defaultSyncEngine) var syncEngine + syncEngine.userDidAcceptCloudKitShare(with: cloudKitShareMetadata) + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 39a3b828..5b5752ae 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -27,6 +27,12 @@ public final actor SyncEngine { let defaultSyncEngine: (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol let _container: any Sendable + let operationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + return queue + }() + public init( container: CKContainer, database: any DatabaseWriter, @@ -38,7 +44,7 @@ public final actor SyncEngine { defaultSyncEngine: { database, syncEngine in CKSyncEngine( CKSyncEngine.Configuration( - database: container.privateCloudDatabase, + database: container.sharedCloudDatabase, stateSerialization: try? database.read { db in // TODO: write test for this try StateSerialization.all.fetchOne(db)?.data }, @@ -631,6 +637,7 @@ public final actor SyncEngine { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + print("!!!!!", event) logger.log(event) switch event { @@ -1590,9 +1597,8 @@ extension SyncEngine { } } - modifyOperation.database = container.privateCloudDatabase - let operationQueue = OperationQueue() - operationQueue.maxConcurrentOperationCount = 1 + modifyOperation.database = container.sharedCloudDatabase + // TODO: can this be container.add? operationQueue.addOperation(modifyOperation) } @@ -1669,3 +1675,41 @@ struct NoCKRecordFound: Error {} } } #endif + + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine { + public nonisolated func userDidAcceptCloudKitShare(with metadata: CKShare.Metadata) { + let operation = CKAcceptSharesOperation(shareMetadatas: [metadata]) + operation.perShareResultBlock = { metadata, result in + print(metadata.hierarchicalRootRecordID) + } + operation.acceptSharesResultBlock = { [weak self] result in + guard let self else { return } + Task { + await withErrorReporting { + try await self.underlyingSyncEngine + .fetchChanges( + .init( + scope: .zoneIDs([metadata.hierarchicalRootRecordID!.zoneID]), + operationGroup: nil + ) + ) + } + } + } + + + let metadataFetchOperation = CKFetchShareMetadataOperation(shareURLs: [metadata.share.url!]) + metadataFetchOperation.shouldFetchRootRecord = true + metadataFetchOperation.perShareMetadataResultBlock = { url, result in + print("!!!") + } + container.add(metadataFetchOperation) + + + //operationQueue.addOperation(operation) + operation.qualityOfService = .utility + container.add(operation) + } +} From c68d0c0097cec38ebd154f653e70a49464477e1f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 29 May 2025 17:45:34 -0700 Subject: [PATCH 088/581] revamp triggers and sharing --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 467 +++++++++--------- .../CloudKitTests/CloudKitTests.swift | 11 +- .../CloudKitTests/ForeignKeyTests.swift | 22 +- .../CloudKitTests/TriggerTests.swift | 228 ++++----- .../Internal/BaseCloudKitTests.swift | 2 +- 5 files changed, 357 insertions(+), 373 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5b5752ae..dde1cff6 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -36,7 +36,7 @@ public final actor SyncEngine { public init( container: CKContainer, database: any DatabaseWriter, - logger: Logger = Logger(subsystem: "SharingGRDB", category: "CloudKit"), + logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), tables: [any PrimaryKeyedTable.Type] ) throws { try self.init( @@ -215,8 +215,65 @@ public final actor SyncEngine { ) .execute(db) db.add(function: .isUpdatingWithServerRecord) + db.add(function: .getZoneName) + db.add(function: .getOwnerName) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .willDelete(syncEngine: self)) + + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_inserts" + AFTER INSERT ON \(Metadata.self) + FOR EACH ROW + BEGIN + SELECT + \(raw: String.sharingGRDBCloudKitSchemaName)_didUpdate( + "new"."recordName", + "new"."zoneName", + "new"."ownerName" + ) + WHERE NOT \(raw: String.sharingGRDBCloudKitSchemaName)_isUpdatingWithServerRecord(); + END + """ + ) + .execute(db) + + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_updates" + AFTER UPDATE ON \(Metadata.self) + FOR EACH ROW + BEGIN + SELECT + \(raw: String.sharingGRDBCloudKitSchemaName)_didUpdate( + "new"."recordName", + "new"."zoneName", + "new"."ownerName" + ) + WHERE NOT \(raw: String.sharingGRDBCloudKitSchemaName)_isUpdatingWithServerRecord() + ; + END + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_deletes" + BEFORE DELETE ON \(Metadata.self) + FOR EACH ROW + BEGIN + SELECT + \(raw: String.sharingGRDBCloudKitSchemaName)_willDelete( + "old"."recordName", + "old"."zoneName", + "old"."ownerName" + ) + WHERE NOT \(raw: String.sharingGRDBCloudKitSchemaName)_isUpdatingWithServerRecord(); + END + """ + ) + .execute(db) + for table in tables { func open(_: T.Type) throws { try createTriggers(table: table, db: db) @@ -234,8 +291,25 @@ public final actor SyncEngine { } try open(table) } + try SQLQueryExpression( + """ + DROP TRIGGER "metadata_deletes" + """ + ).execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER "metadata_updates" + """ + ).execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER "metadata_inserts" + """ + ).execute(db) db.remove(function: .willDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) + db.remove(function: .getOwnerName) + db.remove(function: .getZoneName) db.remove(function: .isUpdatingWithServerRecord) } try database.writeWithoutTransaction { db in @@ -269,26 +343,32 @@ public final actor SyncEngine { try setUpSyncEngine() } - func didUpdate(recordName: String, zoneName: String) { + func didUpdate(recordName: String, zoneName: String, ownerName: String) { underlyingSyncEngine.state.add( pendingRecordZoneChanges: [ .saveRecord( CKRecord.ID( recordName: recordName, - zoneID: CKRecordZone(zoneName: zoneName).zoneID + zoneID: CKRecordZone.ID( + zoneName: zoneName, + ownerName: ownerName + ) ) ) ] ) } - func willDelete(recordName: String, zoneName: String) { + func willDelete(recordName: String, zoneName: String, ownerName: String) { underlyingSyncEngine.state.add( pendingRecordZoneChanges: [ .deleteRecord( CKRecord.ID( recordName: recordName, - zoneID: CKRecordZone(zoneName: zoneName).zoneID + zoneID: CKRecordZone.ID( + zoneName: zoneName, + ownerName: ownerName + ) ) ) ] @@ -321,16 +401,9 @@ public final actor SyncEngine { } private func createTriggers(table: T.Type, db: Database) throws { - try Trigger(on: T.self, .after, .insert, select: .didUpdate(syncEngine: self)).create - .execute(db) - try Trigger(on: T.self, .after, .update, select: .didUpdate(syncEngine: self)).create - .execute(db) - try Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).create - .execute(db) - let from = foreignKeysByTableName[T.tableName]?.count(where: \.notnull) == 1 - ? foreignKeysByTableName[T.tableName]?.first(where: \.notnull)?.from + ? foreignKeysByTableName[T.tableName]?.first(where: \.notnull)?.from : nil try SQLQueryExpression( @@ -343,10 +416,20 @@ public final actor SyncEngine { SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), - \(quote: Self.defaultZone.zoneID.zoneName, delimiter: .text), - \(quote: Self.defaultZone.zoneID.ownerName, delimiter: .text), - \(raw: from.map { #""new"."\#($0)""# } ?? "NULL"), + coalesce( + "zoneName", + \(raw: String.sharingGRDBCloudKitSchemaName)_getZoneName(), + \(quote: Self.defaultZone.zoneID.zoneName, delimiter: .text) + ), + coalesce( + "ownerName", + \(raw: String.sharingGRDBCloudKitSchemaName)_getOwnerName(), + \(quote: Self.defaultZone.zoneID.ownerName, delimiter: .text) + ), + \(raw: from.map { #""new"."\#($0)""# } ?? "NULL") AS "foreignKeyName", datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "\(raw: String.sharingGRDBCloudKitSchemaName)_metadata" ON "recordName" = "foreignKeyName" ON CONFLICT("recordName") DO NOTHING; END """ @@ -357,17 +440,13 @@ public final actor SyncEngine { CREATE TEMPORARY TRIGGER "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataUpdates" AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN - INSERT INTO \(Metadata.self) - ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") - SELECT - \(quote: T.tableName, delimiter: .text), - "new".\(quote: T.columns.primaryKey.name), - \(quote: Self.defaultZone.zoneID.zoneName, delimiter: .text), - \(quote: Self.defaultZone.zoneID.ownerName, delimiter: .text), - \(raw: from.map { #""new"."\#($0)""# } ?? "NULL") - ON CONFLICT("recordName") DO UPDATE SET + UPDATE \(Metadata.self) + SET + "recordName" = "new".\(quote: T.columns.primaryKey.name), "userModificationDate" = datetime('subsec'), - "parentRecordName" = "excluded"."parentRecordName"; + "parentRecordName" = \(raw: from.map { #""new"."\#($0)""# } ?? "NULL") + WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name) + ; END """ ) @@ -378,8 +457,7 @@ public final actor SyncEngine { "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataDeletes" AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN DELETE FROM \(Metadata.self) - WHERE "recordType" = \(quote: T.tableName, delimiter: .text) - AND "recordName" = "old".\(quote: T.columns.primaryKey.name); + WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name); END """ ) @@ -619,25 +697,12 @@ public final actor SyncEngine { """ ) .execute(db) - try SQLQueryExpression( - Trigger(on: T.self, .before, .delete, select: .willDelete(syncEngine: self)).drop - ) - .execute(db) - try SQLQueryExpression( - Trigger(on: T.self, .after, .update, select: .didUpdate(syncEngine: self)).drop - ) - .execute(db) - try SQLQueryExpression( - Trigger(on: T.self, .after, .insert, select: .didUpdate(syncEngine: self)).drop - ) - .execute(db) } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - print("!!!!!", event) logger.log(event) switch event { @@ -796,7 +861,8 @@ extension SyncEngine: CKSyncEngineDelegate { private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { switch event.changeType { case .signIn: - underlyingSyncEngine.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) + // TODO: handle this + //underlyingSyncEngine.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) for table in tables { withErrorReporting(.sharingGRDBCloudKitFailure) { let names: [String] = try database.read { db in @@ -913,11 +979,13 @@ extension SyncEngine: CKSyncEngineDelegate { func clearServerRecord() { withErrorReporting { - try database.write { db in - try Metadata - .find(recordID: failedRecord.recordID) - .update { $0.lastKnownServerRecord = nil } - .execute(db) + try $isUpdatingWithServerRecord.withValue(true) { + try database.write { db in + try Metadata + .find(recordID: failedRecord.recordID) + .update { $0.lastKnownServerRecord = nil } + .execute(db) + } } } } @@ -931,7 +999,8 @@ extension SyncEngine: CKSyncEngineDelegate { case .zoneNotFound: let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) - newPendingDatabaseChanges.append(.saveZone(zone)) + // TODO: handle this + //newPendingDatabaseChanges.append(.saveZone(zone)) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) clearServerRecord() @@ -951,97 +1020,105 @@ extension SyncEngine: CKSyncEngineDelegate { } private func mergeFromServerRecord(_ record: CKRecord) { - withErrorReporting(.sharingGRDBCloudKitFailure) { - let userModificationDate = - try metadatabase.read { db in - try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db) - } - ?? nil - guard let table = tablesByName[record.recordType] - else { - reportIssue( - .sharingGRDBCloudKitFailure.appending( - """ - : No table to merge from: "\(record.recordType)" - """ - ) - ) - return - } - guard - let userModificationDate, - userModificationDate > record.userModificationDate ?? .distantPast - else { - let columnNames = try database.read { db in - try SQLQueryExpression( - """ - SELECT "name" - FROM pragma_table_info(\(bind: table.tableName)) - """, - as: String.self - ) - .fetchAll(db) - } - var query: QueryFragment = "INSERT INTO \(table) (" - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) - query.append(") VALUES (") - let encryptedValues = record.encryptedValues - query.append( - columnNames - .map { columnName in - encryptedValues[columnName]?.queryFragment ?? "NULL" + $isUpdatingWithServerRecord.withValue(true) { + $currentZoneID.withValue(record.recordID.zoneID) { + withErrorReporting(.sharingGRDBCloudKitFailure) { + let userModificationDate = + try metadatabase.read { db in + try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne( + db + ) } - .joined(separator: ", ") - ) - func open(_: T.Type) { - query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET") - } - open(table) - query.append( - columnNames - .map { - """ - \(quote: $0) = "excluded".\(quote: $0) - """ + ?? nil + guard let table = tablesByName[record.recordType] + else { + reportIssue( + .sharingGRDBCloudKitFailure.appending( + """ + : No table to merge from: "\(record.recordType)" + """ + ) + ) + return + } + guard + let userModificationDate, + userModificationDate > record.userModificationDate ?? .distantPast + else { + let columnNames = try database.read { db in + try SQLQueryExpression( + """ + SELECT "name" + FROM pragma_table_info(\(bind: table.tableName)) + """, + as: String.self + ) + .fetchAll(db) } - .joined(separator: ",") - ) - try database.write { db in - try $isUpdatingWithServerRecord.withValue(true) { - try SQLQueryExpression(query).execute(db) - try Metadata - .insert(Metadata(record: record)) { - $0.lastKnownServerRecord = record - $0.userModificationDate = record.userModificationDate - } - .execute(db) + var query: QueryFragment = "INSERT INTO \(table) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + let encryptedValues = record.encryptedValues + query.append( + columnNames + .map { columnName in + encryptedValues[columnName]?.queryFragment ?? "NULL" + } + .joined(separator: ", ") + ) + func open(_: T.Type) { + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET") + } + open(table) + query.append( + columnNames + .map { + """ + \(quote: $0) = "excluded".\(quote: $0) + """ + } + .joined(separator: ",") + ) + try database.write { db in + try SQLQueryExpression(query).execute(db) + try Metadata + .insert(Metadata(record: record)) { + $0.lastKnownServerRecord = record + $0.userModificationDate = record.userModificationDate + } + .execute(db) + } + return } } - return } } } private func refreshLastKnownServerRecord(_ record: CKRecord) { - let metadata = metadataFor(recordID: record.recordID) - - func updateLastKnownServerRecord() { - withErrorReporting(.sharingGRDBCloudKitFailure) { - try database.write { db in - try Metadata - .find(recordID: record.recordID) - .update { $0.lastKnownServerRecord = record } - .execute(db) + $currentZoneID.withValue(record.recordID.zoneID) { + $isUpdatingWithServerRecord.withValue(true) { + let metadata = metadataFor(recordID: record.recordID) + + func updateLastKnownServerRecord() { + withErrorReporting(.sharingGRDBCloudKitFailure) { + try database.write { db in + try Metadata + .find(recordID: record.recordID) + .update { $0.lastKnownServerRecord = record } + .execute(db) + } + } } - } - } - if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { - if let recordDate = record.modificationDate, lastKnownDate < recordDate { - updateLastKnownServerRecord() + if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { + if let recordDate = record.modificationDate, lastKnownDate < recordDate { + updateLastKnownServerRecord() + } + } else { + updateLastKnownServerRecord() + } } - } else { - updateLastKnownServerRecord() } } @@ -1065,42 +1142,59 @@ extension SyncEngine: TestDependencyKey { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { recordName, _ in + Self("didUpdate") { recordName, zoneName, ownerName in await syncEngine .didUpdate( recordName: recordName, - zoneName: SyncEngine.defaultZone.zoneID.zoneName + zoneName: zoneName, + ownerName: ownerName ) } } fileprivate static func willDelete(syncEngine: SyncEngine) -> Self { - return Self("willDelete") { recordName, _ in + return Self("willDelete") { recordName, zoneName, ownerName in await syncEngine.willDelete( recordName: recordName, - zoneName: SyncEngine.defaultZone.zoneID.zoneName + zoneName: zoneName, + ownerName: ownerName ) } } fileprivate static var isUpdatingWithServerRecord: Self { - Self(.sharingGRDBCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { _ in + Self(.sharingGRDBCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { + _ in SharingGRDBCore.isUpdatingWithServerRecord } } + fileprivate static var getZoneName: Self { + Self(.sharingGRDBCloudKitSchemaName + "_" + "getZoneName", argumentCount: 0) { _ in + SharingGRDBCore.currentZoneID?.zoneName + } + } + + fileprivate static var getOwnerName: Self { + Self(.sharingGRDBCloudKitSchemaName + "_" + "getOwnerName", argumentCount: 0) { _ in + SharingGRDBCore.currentZoneID?.ownerName + } + } + private convenience init( _ name: String, - function: @escaping @Sendable (String, String) async -> Void + function: @escaping @Sendable (String, String, String) async -> Void ) { - self.init(.sharingGRDBCloudKitSchemaName + "_" + name, argumentCount: 2) { arguments in + self.init(.sharingGRDBCloudKitSchemaName + "_" + name, argumentCount: 3) { arguments in guard - let tableName = String.fromDatabaseValue(arguments[0]), - let id = String.fromDatabaseValue(arguments[1]) + let recordName = String.fromDatabaseValue(arguments[0]), + let zoneName = String.fromDatabaseValue(arguments[1]), + let ownerName = String.fromDatabaseValue(arguments[2]) else { return nil } - Task { await function(tableName, id) } + // TODO: can we get rid of task by making stuff in actor non-isolated? + Task { await function(recordName, zoneName, ownerName) } return nil } } @@ -1117,8 +1211,7 @@ private struct ForeignKey: QueryDecodable, QueryRepresentable { static func all( _ table: T.Type - ) -> some StructuredQueriesCore.Statement - { + ) -> some StructuredQueriesCore.Statement { SQLQueryExpression( """ SELECT \(ForeignKey.columns) @@ -1167,54 +1260,7 @@ private struct ForeignKey: QueryDecodable, QueryRepresentable { // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates @TaskLocal private var isUpdatingWithServerRecord = false - -private struct Trigger { - typealias QueryValue = Void - - let function: DatabaseFunction - let operation: Operation - let when: When - - init(on _: Base.Type, _ when: When, _ operation: Operation, select function: DatabaseFunction) { - self.function = function - self.operation = operation - self.when = when - } - - var name: QueryFragment { - "\(quote: "\(String.sharingGRDBCloudKitSchemaName)_\(operation.rawValue.string.lowercased())_\(Base.tableName)")" - } - - var create: some StructuredQueriesCore.Statement { - SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER \(name) - \(when.rawValue) \(operation.rawValue) ON \(quote: Base.tableName) FOR EACH ROW BEGIN - SELECT \(raw: function.name)( - \(quote: operation == .delete ? "old" : "new").\(quote: Base.columns.primaryKey.name), - \(quote: Base.tableName, delimiter: .text) - ) - WHERE NOT \(raw: String.sharingGRDBCloudKitSchemaName)_isUpdatingWithServerRecord(); - END - """ - ) - } - - var drop: QueryFragment { - "DROP TRIGGER \(name)" - } - - enum Operation: QueryFragment { - case insert = "INSERT" - case update = "UPDATE" - case delete = "DELETE" - } - - enum When: QueryFragment { - case before = "BEFORE" - case after = "AFTER" - } -} +@TaskLocal private var currentZoneID: CKRecordZone.ID? extension __CKRecordObjCValue { fileprivate var queryFragment: QueryFragment { @@ -1261,33 +1307,6 @@ extension String { fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" } -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -extension DatabaseWriter where Self == DatabasePool { - init(container: CKContainer) throws { - let path = URL.metadatabase(container: container).path(percentEncoded: false) - var configuration = Configuration() - configuration.prepareDatabase { db in - db.trace { - logger.debug("\($0.expandedDescription)") - } - } - logger.debug( - """ - SharingGRDB: Metadatabase connection: - open "\(path)" - """ - ) - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - try self.init( - path: path, - configuration: configuration - ) - } -} - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) extension URL { fileprivate static func metadatabase(container: CKContainer) -> Self { @@ -1297,9 +1316,6 @@ extension URL { } } -@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -private let logger = Logger(subsystem: "SQLiteData", category: "CloudKit") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Logger { func log(_ event: CKSyncEngine.Event) { @@ -1340,7 +1356,7 @@ extension Logger { ? "⚪️ No deletions" : "✅ Zones deleted (\(event.deletions.count): " + event.deletions - .map { $0.zoneID.zoneName } + .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } .sorted() .joined(separator: ", ") debug( @@ -1382,7 +1398,7 @@ extension Logger { ) case .sentDatabaseChanges(let event): let savedZoneNames = event.savedZones - .map { $0.zoneID.zoneName } + .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } .sorted() .joined(separator: ", ") let savedZones = @@ -1399,7 +1415,7 @@ extension Logger { : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" let failedZoneSaveNames = event.failedZoneSaves - .map { $0.zone.zoneID.zoneName } + .map { $0.zone.zoneID.zoneName + ":" + $0.zone.zoneID.ownerName } .sorted() .joined(separator: ", ") let failedZoneSaves = @@ -1438,7 +1454,7 @@ extension Logger { let failedRecordSavesByZoneName = Dictionary( grouping: event.failedRecordSaves, - by: \.record.recordID.zoneID.zoneName + by: { $0.record.recordID.zoneID.zoneName + ":" + $0.record.recordID.zoneID.ownerName } ) let failedRecordSaves = failedRecordSavesByZoneName.keys .sorted() @@ -1508,7 +1524,7 @@ extension Logger { debug( """ \(prefix) willFetchRecordZoneChanges - ✅ Zone: \(event.zoneID.zoneName)\(error) + ✅ Zone: \(event.zoneID.zoneName):\(event.zoneID.ownerName)\(error) """ ) case .didFetchChanges(let event): @@ -1580,7 +1596,7 @@ extension SyncEngine { recordName: UUID().uuidString, zoneID: lastKnownServerRecord.recordID.zoneID ) - let share = CKShare.init(rootRecord: lastKnownServerRecord, shareID: shareID) + let share = CKShare(rootRecord: lastKnownServerRecord, shareID: shareID) configure(share) let modifyOperation = CKModifyRecordsOperation( @@ -1676,7 +1692,6 @@ struct NoCKRecordFound: Error {} } #endif - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { public nonisolated func userDidAcceptCloudKitShare(with metadata: CKShare.Metadata) { @@ -1699,15 +1714,13 @@ extension SyncEngine { } } - let metadataFetchOperation = CKFetchShareMetadataOperation(shareURLs: [metadata.share.url!]) metadataFetchOperation.shouldFetchRootRecord = true metadataFetchOperation.perShareMetadataResultBlock = { url, result in - print("!!!") + //print("!!!") } container.add(metadataFetchOperation) - //operationQueue.addOperation(operation) operation.qualityOfService = .utility container.add(operation) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index b9c24fa7..fd0a09d9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -54,7 +54,6 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDownAndReSetUp() async throws { try await syncEngine.tearDownSyncEngine() - try await syncEngine.setUpSyncEngine() // TODO: it would be nice if `setUpSyncEngine` was async try await Task.sleep(for: .seconds(0.1)) @@ -69,6 +68,7 @@ extension BaseCloudKitTests { .saveRecord(CKRecord.ID(UUID(1))) ]) + let record = CKRecord( recordType: "remindersLists", recordID: CKRecord.ID(UUID(1)) @@ -77,6 +77,7 @@ extension BaseCloudKitTests { modifications: [record], deletions: [] ) + try await Task.sleep(for: .seconds(1)) expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Personal") @@ -105,9 +106,11 @@ extension BaseCloudKitTests { ) { """ [ - [0]: "sqlitedata_icloud_didupdate", - [1]: "sqlitedata_icloud_willdelete", - [2]: "sqlitedata_icloud_isupdatingwithserverrecord" + [0]: "sqlitedata_icloud_getzonename", + [1]: "sqlitedata_icloud_didupdate", + [2]: "sqlitedata_icloud_getownername", + [3]: "sqlitedata_icloud_willdelete", + [4]: "sqlitedata_icloud_isupdatingwithserverrecord" ] """ } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 428ce754..5cdfa17c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -79,35 +79,35 @@ extension BaseCloudKitTests { try database.write { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(2), title: "Walk", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Haircut", remindersListID: UUID(1)) + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Walk", remindersListID: UUID(1)) + Reminder(id: UUID(4), title: "Haircut", remindersListID: UUID(1)) } } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(2))), .saveRecord(CKRecord.ID(UUID(3))), + .saveRecord(CKRecord.ID(UUID(4))), ]) - let newID = try database.write { db in - try RemindersList.find(UUID(1)).update { $0.id = UUID() }.returning(\.id).fetchOne(db)! + try database.write { db in + try RemindersList.find(UUID(1)).update { $0.id = UUID(9) }.execute(db) } try database.read { db in try expectNoDifference( Reminder.all.fetchAll(db), [ - Reminder(id: UUID(1), title: "Groceries", remindersListID: newID), - Reminder(id: UUID(2), title: "Walk", remindersListID: newID), - Reminder(id: UUID(3), title: "Haircut", remindersListID: newID) + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(9)), + Reminder(id: UUID(3), title: "Walk", remindersListID: UUID(9)), + Reminder(id: UUID(4), title: "Haircut", remindersListID: UUID(9)) ] ) } underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(newID)), - .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(9))), .saveRecord(CKRecord.ID(UUID(2))), .saveRecord(CKRecord.ID(UUID(3))), + .saveRecord(CKRecord.ID(UUID(4))), ]) } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 981fb009..677812f9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -8,7 +8,7 @@ import Testing extension BaseCloudKitTests { final class TriggerTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func setUpAndTearDown() async throws { + @Test func triggers() async throws { let triggersAfterSetUp = try await database.write { db in try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } @@ -16,32 +16,45 @@ extension BaseCloudKitTests { #""" [ [0]: """ - CREATE TRIGGER "sqlitedata_icloud_insert_reminders" - AFTER INSERT ON "reminders" FOR EACH ROW BEGIN - SELECT sqlitedata_icloud_didUpdate( - "new"."id", - 'reminders' - ) + CREATE TRIGGER "metadata_inserts" + AFTER INSERT ON "sqlitedata_icloud_metadata" + FOR EACH ROW + BEGIN + SELECT + sqlitedata_icloud_didUpdate( + "new"."recordName", + "new"."zoneName", + "new"."ownerName" + ) WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, [1]: """ - CREATE TRIGGER "sqlitedata_icloud_update_reminders" - AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - SELECT sqlitedata_icloud_didUpdate( - "new"."id", - 'reminders' - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); + CREATE TRIGGER "metadata_updates" + AFTER UPDATE ON "sqlitedata_icloud_metadata" + FOR EACH ROW + BEGIN + SELECT + sqlitedata_icloud_didUpdate( + "new"."recordName", + "new"."zoneName", + "new"."ownerName" + ) + WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord() + ; END """, [2]: """ - CREATE TRIGGER "sqlitedata_icloud_delete_reminders" - BEFORE DELETE ON "reminders" FOR EACH ROW BEGIN - SELECT sqlitedata_icloud_willDelete( - "old"."id", - 'reminders' - ) + CREATE TRIGGER "metadata_deletes" + BEFORE DELETE ON "sqlitedata_icloud_metadata" + FOR EACH ROW + BEGIN + SELECT + sqlitedata_icloud_willDelete( + "old"."recordName", + "old"."zoneName", + "old"."ownerName" + ) WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); END """, @@ -53,35 +66,40 @@ extension BaseCloudKitTests { SELECT 'reminders', "new"."id", - 'co.pointfree.SharingGRDB.defaultZone', - '__defaultOwner__', - "new"."remindersListID", + coalesce( + "zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SharingGRDB.defaultZone' + ), + coalesce( + "ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + "new"."remindersListID" AS "foreignKeyName", datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "recordName" = "foreignKeyName" ON CONFLICT("recordName") DO NOTHING; END """, [4]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_metadataUpdates" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") - SELECT - 'reminders', - "new"."id", - 'co.pointfree.SharingGRDB.defaultZone', - '__defaultOwner__', - "new"."remindersListID" - ON CONFLICT("recordName") DO UPDATE SET + UPDATE "sqlitedata_icloud_metadata" + SET + "recordName" = "new"."id", "userModificationDate" = datetime('subsec'), - "parentRecordName" = "excluded"."parentRecordName"; + "parentRecordName" = "new"."remindersListID" + WHERE "recordName" = "old"."id" + ; END """, [5]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_metadataDeletes" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE "recordType" = 'reminders' - AND "recordName" = "old"."id"; + WHERE "recordName" = "old"."id"; END """, [6]: """ @@ -137,36 +155,6 @@ extension BaseCloudKitTests { END """, [12]: """ - CREATE TRIGGER "sqlitedata_icloud_insert_remindersLists" - AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN - SELECT sqlitedata_icloud_didUpdate( - "new"."id", - 'remindersLists' - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); - END - """, - [13]: """ - CREATE TRIGGER "sqlitedata_icloud_update_remindersLists" - AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - SELECT sqlitedata_icloud_didUpdate( - "new"."id", - 'remindersLists' - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); - END - """, - [14]: """ - CREATE TRIGGER "sqlitedata_icloud_delete_remindersLists" - BEFORE DELETE ON "remindersLists" FOR EACH ROW BEGIN - SELECT sqlitedata_icloud_willDelete( - "old"."id", - 'remindersLists' - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); - END - """, - [15]: """ CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataInserts" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" @@ -174,68 +162,43 @@ extension BaseCloudKitTests { SELECT 'remindersLists', "new"."id", - 'co.pointfree.SharingGRDB.defaultZone', - '__defaultOwner__', - NULL, + coalesce( + "zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SharingGRDB.defaultZone' + ), + coalesce( + "ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKeyName", datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "recordName" = "foreignKeyName" ON CONFLICT("recordName") DO NOTHING; END """, - [16]: """ + [13]: """ CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataUpdates" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") - SELECT - 'remindersLists', - "new"."id", - 'co.pointfree.SharingGRDB.defaultZone', - '__defaultOwner__', - NULL - ON CONFLICT("recordName") DO UPDATE SET + UPDATE "sqlitedata_icloud_metadata" + SET + "recordName" = "new"."id", "userModificationDate" = datetime('subsec'), - "parentRecordName" = "excluded"."parentRecordName"; + "parentRecordName" = NULL + WHERE "recordName" = "old"."id" + ; END """, - [17]: """ + [14]: """ CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataDeletes" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE "recordType" = 'remindersLists' - AND "recordName" = "old"."id"; - END - """, - [18]: """ - CREATE TRIGGER "sqlitedata_icloud_insert_users" - AFTER INSERT ON "users" FOR EACH ROW BEGIN - SELECT sqlitedata_icloud_didUpdate( - "new"."id", - 'users' - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); - END - """, - [19]: """ - CREATE TRIGGER "sqlitedata_icloud_update_users" - AFTER UPDATE ON "users" FOR EACH ROW BEGIN - SELECT sqlitedata_icloud_didUpdate( - "new"."id", - 'users' - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); + WHERE "recordName" = "old"."id"; END """, - [20]: """ - CREATE TRIGGER "sqlitedata_icloud_delete_users" - BEFORE DELETE ON "users" FOR EACH ROW BEGIN - SELECT sqlitedata_icloud_willDelete( - "old"."id", - 'users' - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); - END - """, - [21]: """ + [15]: """ CREATE TRIGGER "sqlitedata_icloud_users_metadataInserts" AFTER INSERT ON "users" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" @@ -243,35 +206,40 @@ extension BaseCloudKitTests { SELECT 'users', "new"."id", - 'co.pointfree.SharingGRDB.defaultZone', - '__defaultOwner__', - NULL, + coalesce( + "zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SharingGRDB.defaultZone' + ), + coalesce( + "ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKeyName", datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "recordName" = "foreignKeyName" ON CONFLICT("recordName") DO NOTHING; END """, - [22]: """ + [16]: """ CREATE TRIGGER "sqlitedata_icloud_users_metadataUpdates" AFTER UPDATE ON "users" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName") - SELECT - 'users', - "new"."id", - 'co.pointfree.SharingGRDB.defaultZone', - '__defaultOwner__', - NULL - ON CONFLICT("recordName") DO UPDATE SET + UPDATE "sqlitedata_icloud_metadata" + SET + "recordName" = "new"."id", "userModificationDate" = datetime('subsec'), - "parentRecordName" = "excluded"."parentRecordName"; + "parentRecordName" = NULL + WHERE "recordName" = "old"."id" + ; END """, - [23]: """ + [17]: """ CREATE TRIGGER "sqlitedata_icloud_users_metadataDeletes" AFTER DELETE ON "users" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE "recordType" = 'users' - AND "recordName" = "old"."id"; + WHERE "recordName" = "old"."id"; END """ ] diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 0d05eef6..5b7325ac 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -3,7 +3,7 @@ import SharingGRDB import SnapshotTesting import Testing -@Suite(.serialized, .snapshots(record: .failed)) +@Suite(.snapshots(record: .failed)) class BaseCloudKitTests: @unchecked Sendable { let database: any DatabaseWriter private let _syncEngine: any Sendable From 564125da4c95ceb65870cc3d53fff969f24f1772 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 11:38:13 -0700 Subject: [PATCH 089/581] move code around --- .../CloudKit/CKSyncEngineProtocol.swift | 29 ++ .../CloudKit/CloudKit+StructuredQueries.swift | 40 ++ .../CloudKit/DefaultSyncEngine.swift | 18 + .../SharingGRDBCore/CloudKit/ForeignKey.swift | 59 +++ .../SharingGRDBCore/CloudKit/Logging.swift | 229 +++++++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 370 +----------------- 6 files changed, 376 insertions(+), 369 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/ForeignKey.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/Logging.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift new file mode 100644 index 00000000..12adeb87 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift @@ -0,0 +1,29 @@ +import CloudKit + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package protocol CKSyncEngineProtocol: Sendable { + associatedtype State: CKSyncEngineStateProtocol + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws + var state: State { get } +} +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKSyncEngineProtocol { + package func fetchChanges() async throws { + try await fetchChanges(CKSyncEngine.FetchChangesOptions()) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package protocol CKSyncEngineStateProtocol: Sendable { + func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) + func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) + func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) + func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKSyncEngine: CKSyncEngineProtocol { +} +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKSyncEngine.State: CKSyncEngineStateProtocol { +} diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 7e99eda4..9d717eeb 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -84,6 +84,46 @@ extension PrimaryKeyedTable { } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Metadata { + package static func find(recordID: CKRecord.ID) -> Where { + Self.where { + $0.recordName.eq(recordID.recordName) + } + } + + init(record: CKRecord) { + self.init( + recordType: record.recordType, + recordName: record.recordID.recordName, + zoneName: record.recordID.zoneID.zoneName, + ownerName: record.recordID.zoneID.ownerName, + lastKnownServerRecord: record, + userModificationDate: record.userModificationDate + ) + } +} + +extension __CKRecordObjCValue { + var queryFragment: QueryFragment { + if let value = self as? Int64 { + return value.queryFragment + } else if let value = self as? Double { + return value.queryFragment + } else if let value = self as? String { + return value.queryFragment + } else if let value = self as? Data { + return value.queryFragment + } else if let value = self as? Date { + return value.queryFragment + } else { + return "\(.invalid(Unbindable()))" + } + } +} + +private struct Unbindable: Error {} + // TODO: Move to custom-dump? @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension CKRecord: @retroactive CustomDumpReflectable { diff --git a/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift new file mode 100644 index 00000000..966180a4 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift @@ -0,0 +1,18 @@ +import CloudKit +import Dependencies +import GRDB + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension DependencyValues { + public var defaultSyncEngine: SyncEngine { + get { self[SyncEngine.self] } + set { self[SyncEngine.self] = newValue } + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine: TestDependencyKey { + public static var testValue: SyncEngine { + try! SyncEngine(container: .default(), database: DatabaseQueue(), tables: []) + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift new file mode 100644 index 00000000..5cfd7ed6 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -0,0 +1,59 @@ +import StructuredQueries + +struct ForeignKey: QueryDecodable, QueryRepresentable { + enum Action: String, QueryBindable { + case cascade = "CASCADE" + case restrict = "RESTRICT" + case setDefault = "SET DEFAULT" + case setNull = "SET NULL" + case noAction = "NO ACTION" + } + + static func all( + _ table: T.Type + ) -> some StructuredQueriesCore.Statement { + SQLQueryExpression( + """ + SELECT \(ForeignKey.columns) + FROM pragma_foreign_key_list(\(bind: table.tableName)) AS "foreign_keys" + JOIN pragma_table_info(\(bind: table.tableName)) AS "table_info" + ON "foreign_keys"."from" = "table_info"."name" + """, + as: ForeignKey.self + ) + } + + typealias QueryValue = Self + + let table: String + let from: String + let to: String + let onUpdate: Action + let onDelete: Action + let notnull: Bool + + init(decoder: inout some QueryDecoder) throws { + guard + let table = try decoder.decode(String.self), + let from = try decoder.decode(String.self), + let to = try decoder.decode(String.self), + let onUpdate = try decoder.decode(Action.self), + let onDelete = try decoder.decode(Action.self), + let notnull = try decoder.decode(Bool.self) + else { + throw QueryDecodingError.missingRequiredColumn + } + self.table = table + self.from = from + self.to = to + self.onUpdate = onUpdate + self.onDelete = onDelete + self.notnull = notnull + } + + static var columns: QueryFragment { + """ + "table", "from", "to", "on_update", "on_delete", "notnull" + """ + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift new file mode 100644 index 00000000..d16b2924 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -0,0 +1,229 @@ +import CloudKit +import os + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Logger { + func log(_ event: CKSyncEngine.Event) { + let prefix = "handleEvent:" + switch event { + case .stateUpdate: + debug("\(prefix) stateUpdate") + case .accountChange(let event): + switch event.changeType { + case .signIn(let currentUser): + debug( + """ + \(prefix) signIn + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + case .signOut(let previousUser): + debug( + """ + \(prefix) signOut + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + """ + ) + case .switchAccounts(let previousUser, let currentUser): + debug( + """ + \(prefix) switchAccounts: + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + @unknown default: + debug("unknown") + } + case .fetchedDatabaseChanges(let event): + let deletions = + event.deletions.isEmpty + ? "⚪️ No deletions" + : "✅ Zones deleted (\(event.deletions.count): " + + event.deletions + .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } + .sorted() + .joined(separator: ", ") + debug( + """ + \(prefix) fetchedDatabaseChanges + \(deletions) + """ + ) + case .fetchedRecordZoneChanges(let event): + let deletionsByRecordType = Dictionary( + grouping: event.deletions, + by: \.recordType + ) + let recordTypeDeletions = deletionsByRecordType.keys.sorted() + .map { recordType in "\(recordType) (\(deletionsByRecordType[recordType]!.count))" } + .joined(separator: ", ") + let deletions = + event.deletions.isEmpty + ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(recordTypeDeletions)" + + let modificationsByRecordType = Dictionary( + grouping: event.modifications, + by: \.record.recordType + ) + let recordTypeModifications = modificationsByRecordType.keys.sorted() + .map { recordType in "\(recordType) (\(modificationsByRecordType[recordType]!.count))" } + .joined(separator: ", ") + let modifications = + event.modifications.isEmpty + ? "⚪️ No modifications" + : "✅ Records modified (\(event.modifications.count)): \(recordTypeModifications)" + + debug( + """ + \(prefix) fetchedRecordZoneChanges + \(modifications) + \(deletions) + """ + ) + case .sentDatabaseChanges(let event): + let savedZoneNames = event.savedZones + .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } + .sorted() + .joined(separator: ", ") + let savedZones = + event.savedZones.isEmpty + ? "⚪️ No saved zones" : "✅ Saved zones (\(event.savedZones.count)): \(savedZoneNames)" + + let deletedZoneNames = event.deletedZoneIDs + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let deletedZones = + event.deletedZoneIDs.isEmpty + ? "⚪️ No deleted zones" + : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" + + let failedZoneSaveNames = event.failedZoneSaves + .map { $0.zone.zoneID.zoneName + ":" + $0.zone.zoneID.ownerName } + .sorted() + .joined(separator: ", ") + let failedZoneSaves = + event.failedZoneSaves.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(event.failedZoneSaves.count)): \(failedZoneSaveNames)" + + let failedZoneDeleteNames = event.failedZoneDeletes + .keys + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let failedZoneDeletes = + event.failedZoneDeletes.isEmpty + ? "⚪️ No failed deleted zones" + : "🛑 Failed zone delete (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" + + debug( + """ + \(prefix) sentDatabaseChanges + \(savedZones) + \(deletedZones) + \(failedZoneSaves) + \(failedZoneDeletes) + """ + ) + case .sentRecordZoneChanges(let event): + let savedRecordsByRecordType = Dictionary( + grouping: event.savedRecords, + by: \.recordType + ) + let savedRecords = savedRecordsByRecordType.keys + .sorted() + .map { "\($0) (\(savedRecordsByRecordType[$0]!.count))" } + .joined(separator: ", ") + + let failedRecordSavesByZoneName = Dictionary( + grouping: event.failedRecordSaves, + by: { $0.record.recordID.zoneID.zoneName + ":" + $0.record.recordID.zoneID.ownerName } + ) + let failedRecordSaves = failedRecordSavesByZoneName.keys + .sorted() + .map { "\($0) (\(failedRecordSavesByZoneName[$0]!.count))" } + .joined(separator: ", ") + + debug( + """ + \(prefix) sentRecordZoneChanges + \(savedRecordsByRecordType.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") + \(event.deletedRecordIDs.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records (\(event.deletedRecordIDs.count))") + \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)") + \(event.failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(event.failedRecordDeletes.count))") + """ + ) + case .willFetchChanges(let event): + if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { + debug("\(prefix) willFetchChanges: \(event.context.reason.description)") + } else { + debug("\(prefix) willFetchChanges") + } + case .willFetchRecordZoneChanges(let event): + debug("\(prefix) willFetchRecordZoneChanges: \(event.zoneID.zoneName)") + case .didFetchRecordZoneChanges(let event): + let errorType = event.error.map { + switch $0.code { + case .internalError: "internalError" + case .partialFailure: "partialFailure" + case .networkUnavailable: "networkUnavailable" + case .networkFailure: "networkFailure" + case .badContainer: "badContainer" + case .serviceUnavailable: "serviceUnavailable" + case .requestRateLimited: "requestRateLimited" + case .missingEntitlement: "missingEntitlement" + case .notAuthenticated: "notAuthenticated" + case .permissionFailure: "permissionFailure" + case .unknownItem: "unknownItem" + case .invalidArguments: "invalidArguments" + case .resultsTruncated: "resultsTruncated" + case .serverRecordChanged: "serverRecordChanged" + case .serverRejectedRequest: "serverRejectedRequest" + case .assetFileNotFound: "assetFileNotFound" + case .assetFileModified: "assetFileModified" + case .incompatibleVersion: "incompatibleVersion" + case .constraintViolation: "constraintViolation" + case .operationCancelled: "operationCancelled" + case .changeTokenExpired: "changeTokenExpired" + case .batchRequestFailed: "batchRequestFailed" + case .zoneBusy: "zoneBusy" + case .badDatabase: "badDatabase" + case .quotaExceeded: "quotaExceeded" + case .zoneNotFound: "zoneNotFound" + case .limitExceeded: "limitExceeded" + case .userDeletedZone: "userDeletedZone" + case .tooManyParticipants: "tooManyParticipants" + case .alreadyShared: "alreadyShared" + case .referenceViolation: "referenceViolation" + case .managedAccountRestricted: "managedAccountRestricted" + case .participantMayNeedVerification: "participantMayNeedVerification" + case .serverResponseLost: "serverResponseLost" + case .assetNotAvailable: "assetNotAvailable" + case .accountTemporarilyUnavailable: "accountTemporarilyUnavailable" + @unknown default: "unknown" + } + } + let error = errorType.map { "\n ❌ \($0)" } ?? "" + debug( + """ + \(prefix) willFetchRecordZoneChanges + ✅ Zone: \(event.zoneID.zoneName):\(event.zoneID.ownerName)\(error) + """ + ) + case .didFetchChanges(let event): + if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { + debug("\(prefix) didFetchChanges: \(event.context.reason.description)") + } else { + debug("\(prefix) didFetchChanges") + } + case .willSendChanges(let event): + debug("\(prefix) willSendChanges: \(event.context.reason.description)") + case .didSendChanges(let event): + debug("\(prefix) didSendChanges: \(event.context.reason.description)") + @unknown default: + warning("\(prefix) ⚠️ unknown event: \(event.description)") + } + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index dde1cff6..615993e7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -2,14 +2,6 @@ import CloudKit import ConcurrencyExtras import OSLog -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension DependencyValues { - public var defaultSyncEngine: SyncEngine { - get { self[SyncEngine.self] } - set { self[SyncEngine.self] = newValue } - } -} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final actor SyncEngine { public static nonisolated let defaultZone = CKRecordZone( @@ -46,7 +38,7 @@ public final actor SyncEngine { CKSyncEngine.Configuration( database: container.sharedCloudDatabase, stateSerialization: try? database.read { db in // TODO: write test for this - try StateSerialization.all.fetchOne(db)?.data + try StateSerialization.select(\.data).fetchOne(db) }, delegate: syncEngine ) @@ -1132,13 +1124,6 @@ extension SyncEngine: CKSyncEngineDelegate { } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine: TestDependencyKey { - public static var testValue: SyncEngine { - try! SyncEngine(container: .default(), database: DatabaseQueue(), tables: []) - } -} - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { @@ -1200,108 +1185,10 @@ extension DatabaseFunction { } } -private struct ForeignKey: QueryDecodable, QueryRepresentable { - enum Action: String, QueryBindable { - case cascade = "CASCADE" - case restrict = "RESTRICT" - case setDefault = "SET DEFAULT" - case setNull = "SET NULL" - case noAction = "NO ACTION" - } - - static func all( - _ table: T.Type - ) -> some StructuredQueriesCore.Statement { - SQLQueryExpression( - """ - SELECT \(ForeignKey.columns) - FROM pragma_foreign_key_list(\(bind: table.tableName)) AS "foreign_keys" - JOIN pragma_table_info(\(bind: table.tableName)) AS "table_info" - ON "foreign_keys"."from" = "table_info"."name" - """, - as: ForeignKey.self - ) - } - - typealias QueryValue = Self - - let table: String - let from: String - let to: String - let onUpdate: Action - let onDelete: Action - let notnull: Bool - - init(decoder: inout some QueryDecoder) throws { - guard - let table = try decoder.decode(String.self), - let from = try decoder.decode(String.self), - let to = try decoder.decode(String.self), - let onUpdate = try decoder.decode(Action.self), - let onDelete = try decoder.decode(Action.self), - let notnull = try decoder.decode(Bool.self) - else { - throw QueryDecodingError.missingRequiredColumn - } - self.table = table - self.from = from - self.to = to - self.onUpdate = onUpdate - self.onDelete = onDelete - self.notnull = notnull - } - - static var columns: QueryFragment { - """ - "table", "from", "to", "on_update", "on_delete", "notnull" - """ - } -} - // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates @TaskLocal private var isUpdatingWithServerRecord = false @TaskLocal private var currentZoneID: CKRecordZone.ID? -extension __CKRecordObjCValue { - fileprivate var queryFragment: QueryFragment { - if let value = self as? Int64 { - return value.queryFragment - } else if let value = self as? Double { - return value.queryFragment - } else if let value = self as? String { - return value.queryFragment - } else if let value = self as? Data { - return value.queryFragment - } else if let value = self as? Date { - return value.queryFragment - } else { - return "\(.invalid(Unbindable()))" - } - } -} - -private struct Unbindable: Error {} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Metadata { - package static func find(recordID: CKRecord.ID) -> Where { - Self.where { - $0.recordName.eq(recordID.recordName) - } - } - - init(record: CKRecord) { - self.init( - recordType: record.recordType, - recordName: record.recordID.recordName, - zoneName: record.recordID.zoneID.zoneName, - ownerName: record.recordID.zoneID.ownerName, - lastKnownServerRecord: record, - userModificationDate: record.userModificationDate - ) - } -} - extension String { package static let sharingGRDBCloudKitSchemaName = "sqlitedata_icloud" fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" @@ -1316,261 +1203,6 @@ extension URL { } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Logger { - func log(_ event: CKSyncEngine.Event) { - let prefix = "handleEvent:" - switch event { - case .stateUpdate: - debug("\(prefix) stateUpdate") - case .accountChange(let event): - switch event.changeType { - case .signIn(let currentUser): - debug( - """ - \(prefix) signIn - Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) - """ - ) - case .signOut(let previousUser): - debug( - """ - \(prefix) signOut - Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) - """ - ) - case .switchAccounts(let previousUser, let currentUser): - debug( - """ - \(prefix) switchAccounts: - Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) - Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) - """ - ) - @unknown default: - debug("unknown") - } - case .fetchedDatabaseChanges(let event): - let deletions = - event.deletions.isEmpty - ? "⚪️ No deletions" - : "✅ Zones deleted (\(event.deletions.count): " - + event.deletions - .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } - .sorted() - .joined(separator: ", ") - debug( - """ - \(prefix) fetchedDatabaseChanges - \(deletions) - """ - ) - case .fetchedRecordZoneChanges(let event): - let deletionsByRecordType = Dictionary( - grouping: event.deletions, - by: \.recordType - ) - let recordTypeDeletions = deletionsByRecordType.keys.sorted() - .map { recordType in "\(recordType) (\(deletionsByRecordType[recordType]!.count))" } - .joined(separator: ", ") - let deletions = - event.deletions.isEmpty - ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(recordTypeDeletions)" - - let modificationsByRecordType = Dictionary( - grouping: event.modifications, - by: \.record.recordType - ) - let recordTypeModifications = modificationsByRecordType.keys.sorted() - .map { recordType in "\(recordType) (\(modificationsByRecordType[recordType]!.count))" } - .joined(separator: ", ") - let modifications = - event.modifications.isEmpty - ? "⚪️ No modifications" - : "✅ Records modified (\(event.modifications.count)): \(recordTypeModifications)" - - debug( - """ - \(prefix) fetchedRecordZoneChanges - \(modifications) - \(deletions) - """ - ) - case .sentDatabaseChanges(let event): - let savedZoneNames = event.savedZones - .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } - .sorted() - .joined(separator: ", ") - let savedZones = - event.savedZones.isEmpty - ? "⚪️ No saved zones" : "✅ Saved zones (\(event.savedZones.count)): \(savedZoneNames)" - - let deletedZoneNames = event.deletedZoneIDs - .map { $0.zoneName } - .sorted() - .joined(separator: ", ") - let deletedZones = - event.deletedZoneIDs.isEmpty - ? "⚪️ No deleted zones" - : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" - - let failedZoneSaveNames = event.failedZoneSaves - .map { $0.zone.zoneID.zoneName + ":" + $0.zone.zoneID.ownerName } - .sorted() - .joined(separator: ", ") - let failedZoneSaves = - event.failedZoneSaves.isEmpty - ? "⚪️ No failed saved zones" - : "🛑 Failed zone saves (\(event.failedZoneSaves.count)): \(failedZoneSaveNames)" - - let failedZoneDeleteNames = event.failedZoneDeletes - .keys - .map { $0.zoneName } - .sorted() - .joined(separator: ", ") - let failedZoneDeletes = - event.failedZoneDeletes.isEmpty - ? "⚪️ No failed deleted zones" - : "🛑 Failed zone delete (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" - - debug( - """ - \(prefix) sentDatabaseChanges - \(savedZones) - \(deletedZones) - \(failedZoneSaves) - \(failedZoneDeletes) - """ - ) - case .sentRecordZoneChanges(let event): - let savedRecordsByRecordType = Dictionary( - grouping: event.savedRecords, - by: \.recordType - ) - let savedRecords = savedRecordsByRecordType.keys - .sorted() - .map { "\($0) (\(savedRecordsByRecordType[$0]!.count))" } - .joined(separator: ", ") - - let failedRecordSavesByZoneName = Dictionary( - grouping: event.failedRecordSaves, - by: { $0.record.recordID.zoneID.zoneName + ":" + $0.record.recordID.zoneID.ownerName } - ) - let failedRecordSaves = failedRecordSavesByZoneName.keys - .sorted() - .map { "\($0) (\(failedRecordSavesByZoneName[$0]!.count))" } - .joined(separator: ", ") - - debug( - """ - \(prefix) sentRecordZoneChanges - \(savedRecordsByRecordType.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") - \(event.deletedRecordIDs.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records (\(event.deletedRecordIDs.count))") - \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)") - \(event.failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(event.failedRecordDeletes.count))") - """ - ) - case .willFetchChanges(let event): - if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { - debug("\(prefix) willFetchChanges: \(event.context.reason.description)") - } else { - debug("\(prefix) willFetchChanges") - } - case .willFetchRecordZoneChanges(let event): - debug("\(prefix) willFetchRecordZoneChanges: \(event.zoneID.zoneName)") - case .didFetchRecordZoneChanges(let event): - let errorType = event.error.map { - switch $0.code { - case .internalError: "internalError" - case .partialFailure: "partialFailure" - case .networkUnavailable: "networkUnavailable" - case .networkFailure: "networkFailure" - case .badContainer: "badContainer" - case .serviceUnavailable: "serviceUnavailable" - case .requestRateLimited: "requestRateLimited" - case .missingEntitlement: "missingEntitlement" - case .notAuthenticated: "notAuthenticated" - case .permissionFailure: "permissionFailure" - case .unknownItem: "unknownItem" - case .invalidArguments: "invalidArguments" - case .resultsTruncated: "resultsTruncated" - case .serverRecordChanged: "serverRecordChanged" - case .serverRejectedRequest: "serverRejectedRequest" - case .assetFileNotFound: "assetFileNotFound" - case .assetFileModified: "assetFileModified" - case .incompatibleVersion: "incompatibleVersion" - case .constraintViolation: "constraintViolation" - case .operationCancelled: "operationCancelled" - case .changeTokenExpired: "changeTokenExpired" - case .batchRequestFailed: "batchRequestFailed" - case .zoneBusy: "zoneBusy" - case .badDatabase: "badDatabase" - case .quotaExceeded: "quotaExceeded" - case .zoneNotFound: "zoneNotFound" - case .limitExceeded: "limitExceeded" - case .userDeletedZone: "userDeletedZone" - case .tooManyParticipants: "tooManyParticipants" - case .alreadyShared: "alreadyShared" - case .referenceViolation: "referenceViolation" - case .managedAccountRestricted: "managedAccountRestricted" - case .participantMayNeedVerification: "participantMayNeedVerification" - case .serverResponseLost: "serverResponseLost" - case .assetNotAvailable: "assetNotAvailable" - case .accountTemporarilyUnavailable: "accountTemporarilyUnavailable" - @unknown default: "unknown" - } - } - let error = errorType.map { "\n ❌ \($0)" } ?? "" - debug( - """ - \(prefix) willFetchRecordZoneChanges - ✅ Zone: \(event.zoneID.zoneName):\(event.zoneID.ownerName)\(error) - """ - ) - case .didFetchChanges(let event): - if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { - debug("\(prefix) didFetchChanges: \(event.context.reason.description)") - } else { - debug("\(prefix) didFetchChanges") - } - case .willSendChanges(let event): - debug("\(prefix) willSendChanges: \(event.context.reason.description)") - case .didSendChanges(let event): - debug("\(prefix) didSendChanges: \(event.context.reason.description)") - @unknown default: - warning("\(prefix) ⚠️ unknown event: \(event.description)") - } - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package protocol CKSyncEngineProtocol: Sendable { - associatedtype State: CKSyncEngineStateProtocol - func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws - var state: State { get } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngineProtocol { - package func fetchChanges() async throws { - try await fetchChanges(CKSyncEngine.FetchChangesOptions()) - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package protocol CKSyncEngineStateProtocol: Sendable { - func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) - func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) - func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) - func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngine: CKSyncEngineProtocol { -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngine.State: CKSyncEngineStateProtocol { -} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { public func share( From 1f8a3bba2f6e2724446f1d10843c8c9403b2d814 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 14:43:36 -0700 Subject: [PATCH 090/581] lots of trigger clean up; --- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../SharingGRDBCore/CloudKit/ForeignKey.swift | 247 +++++++++- .../SharingGRDBCore/CloudKit/Metadata.swift | 221 ++++++--- .../CloudKit/MetadataTable.swift | 63 +++ ...RecordType.swift => RecordTypeTable.swift} | 4 +- ...on.swift => StateSerializationTable.swift} | 4 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 430 ++---------------- .../CloudKitTests/CloudKitTests.swift | 19 +- .../CloudKitTests/SharingTests.swift | 1 + .../CloudKitTests/TriggerTests.swift | 69 ++- Tests/SharingGRDBTests/Internal/Schema.swift | 16 +- 11 files changed, 579 insertions(+), 497 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/MetadataTable.swift rename Sources/SharingGRDBCore/CloudKit/{RecordType.swift => RecordTypeTable.swift} (94%) rename Sources/SharingGRDBCore/CloudKit/{StateSerialization.swift => StateSerializationTable.swift} (89%) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 9d717eeb..14297163 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -73,7 +73,7 @@ extension CKRecord { } private static let userModificationDateKey = - "\(String.sharingGRDBCloudKitSchemaName)_userModificationDate" + "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" } extension PrimaryKeyedTable { diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index 5cfd7ed6..d6e3cc80 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -1,28 +1,6 @@ import StructuredQueries struct ForeignKey: QueryDecodable, QueryRepresentable { - enum Action: String, QueryBindable { - case cascade = "CASCADE" - case restrict = "RESTRICT" - case setDefault = "SET DEFAULT" - case setNull = "SET NULL" - case noAction = "NO ACTION" - } - - static func all( - _ table: T.Type - ) -> some StructuredQueriesCore.Statement { - SQLQueryExpression( - """ - SELECT \(ForeignKey.columns) - FROM pragma_foreign_key_list(\(bind: table.tableName)) AS "foreign_keys" - JOIN pragma_table_info(\(bind: table.tableName)) AS "table_info" - ON "foreign_keys"."from" = "table_info"."name" - """, - as: ForeignKey.self - ) - } - typealias QueryValue = Self let table: String @@ -51,9 +29,234 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { self.notnull = notnull } + enum Action: String, QueryBindable { + case cascade = "CASCADE" + case restrict = "RESTRICT" + case setDefault = "SET DEFAULT" + case setNull = "SET NULL" + case noAction = "NO ACTION" + } + + static func all( + _ table: T.Type + ) -> some StructuredQueriesCore.Statement { + SQLQueryExpression( + """ + SELECT \(ForeignKey.columns) + FROM pragma_foreign_key_list(\(bind: table.tableName)) AS "foreign_keys" + JOIN pragma_table_info(\(bind: table.tableName)) AS "table_info" + ON "foreign_keys"."from" = "table_info"."name" + """, + as: ForeignKey.self + ) + } + static var columns: QueryFragment { """ "table", "from", "to", "on_update", "on_delete", "notnull" """ } + + func createTriggers(for _: T.Type, db: Database) throws { + switch onDelete { + case .cascade: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteCascade" + AFTER DELETE ON \(quote: table) + FOR EACH ROW BEGIN + DELETE FROM \(T.self) + WHERE \(quote: from) = "old".\(quote: to); + END + """ + ) + .execute(db) + case .restrict: + // TODO: report issue + break + + case .setDefault: + let defaultValue = + try SQLQueryExpression( + """ + SELECT "dflt_value" + FROM pragma_table_info(\(bind: T.tableName)) + WHERE "name" = \(bind: from) + """, + as: String?.self + ) + .fetchOne(db) ?? "NULL" + + guard let defaultValue + else { + // TODO: Report issue? + break + } + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetDefault" + AFTER DELETE ON \(quote: table) + FOR EACH ROW BEGIN + UPDATE \(T.self) + SET \(quote: from) = \(raw: defaultValue) + WHERE \(quote: from) = "old".\(quote: to); + END + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetNull" + AFTER DELETE ON \(quote: table) + FOR EACH ROW BEGIN + UPDATE \(T.self) + SET \(quote: from) = NULL + WHERE \(quote: from) = "old".\(quote: to); + END + """ + ) + .execute(db) + case .noAction: + break + } + + switch onUpdate { + case .cascade: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateCascade" + AFTER UPDATE ON \(quote: table) + FOR EACH ROW BEGIN + UPDATE \(T.self) + SET \(quote: from) = "new".\(quote: to) + WHERE \(quote: from) = "old".\(quote: to); + END + """ + ) + .execute(db) + case .restrict: + // TODO: report issue + break + + case .setDefault: + let defaultValue = + try SQLQueryExpression( + """ + SELECT "dflt_value" + FROM pragma_table_info(\(bind: T.tableName)) + WHERE "name" = \(bind: from) + """, + as: String?.self + ) + .fetchOne(db) ?? "NULL" + + guard let defaultValue + else { + // TODO: Report issue? + break + } + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetDefault" + AFTER UPDATE ON \(quote: table) + FOR EACH ROW BEGIN + UPDATE \(T.self) + SET \(quote: from) = \(raw: defaultValue) + WHERE \(quote: from) = "old".\(quote: to); + END + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetNull" + AFTER UPDATE ON \(quote: table) + FOR EACH ROW BEGIN + UPDATE \(T.self) + SET \(quote: from) = NULL + WHERE \(quote: from) = "old".\(quote: to); + END + """ + ) + .execute(db) + case .noAction: + break + } + } + + func dropTriggers(for _: T.Type, db: Database) throws { + switch onDelete { + case .cascade: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteCascade" + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetNull" + """ + ) + .execute(db) + + case .setDefault: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetDefault" + """ + ) + .execute(db) + + case .restrict, .noAction: + break + } + + switch onUpdate { + case .cascade: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateCascade" + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetNull" + """ + ) + .execute(db) + + case .setDefault: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetDefault" + """ + ) + .execute(db) + + case .restrict, .noAction: + break + } + } } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index bd92ffd8..d4b29baf 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -1,63 +1,172 @@ import CloudKit +import StructuredQueriesCore @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Table("\(String.sharingGRDBCloudKitSchemaName)_metadata") -package struct Metadata { - package var recordType: String - package var recordName: String - package var zoneName: String - package var ownerName: String - package var parentRecordName: String? - // @Column(as: CKRecord?.DataRepresentation.self) - package var lastKnownServerRecord: CKRecord? - package var userModificationDate: Date? -} +extension Metadata { + static func createTriggers( + tables: [any PrimaryKeyedTable.Type], + db: Database + ) throws { + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_inserts" + AFTER INSERT ON \(Metadata.self) + FOR EACH ROW + BEGIN + SELECT + \(raw: String.sqliteDataCloudKitSchemaName)_didUpdate( + "new"."recordName", + "new"."zoneName", + "new"."ownerName" + ) + WHERE NOT \(raw: String.sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord(); + END + """ + ) + .execute(db) -// NB: This is generated by inlining the above macro applications. -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table { - public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = Metadata - public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) - public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) - public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.userModificationDate] - } + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_updates" + AFTER UPDATE ON \(Metadata.self) + FOR EACH ROW + BEGIN + SELECT + \(raw: String.sqliteDataCloudKitSchemaName)_didUpdate( + "new"."recordName", + "new"."zoneName", + "new"."ownerName" + ) + WHERE NOT \(raw: String.sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() + ; + END + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_deletes" + BEFORE DELETE ON \(Metadata.self) + FOR EACH ROW + BEGIN + SELECT + \(raw: String.sqliteDataCloudKitSchemaName)_willDelete( + "old"."recordName", + "old"."zoneName", + "old"."ownerName" + ) + WHERE NOT \(raw: String.sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord(); + END + """ + ) + .execute(db) } - public static let columns = TableColumns() - public static let tableName = "\(String.sharingGRDBCloudKitSchemaName)_metadata" - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let recordType = try decoder.decode(String.self) - let recordName = try decoder.decode(String.self) - let zoneName = try decoder.decode(String.self) - let ownerName = try decoder.decode(String.self) - self.parentRecordName = try decoder.decode(String.self) - let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) - self.userModificationDate = try decoder.decode(Date.self) - guard let recordType else { - throw QueryDecodingError.missingRequiredColumn - } - guard let recordName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let zoneName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let ownerName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let lastKnownServerRecord else { - throw QueryDecodingError.missingRequiredColumn - } - self.recordType = recordType - self.recordName = recordName - self.zoneName = zoneName - self.ownerName = ownerName - self.lastKnownServerRecord = lastKnownServerRecord + + static func dropTriggers(db: Database) throws { + try SQLQueryExpression(#"DROP TRIGGER "metadata_deletes""#).execute(db) + try SQLQueryExpression(#"DROP TRIGGER "metadata_updates""#).execute(db) + try SQLQueryExpression(#"DROP TRIGGER "metadata_inserts""#).execute(db) + } + + static func createTriggers( + for _: T.Type, + parentForeignKey: ForeignKey?, + db: Database + ) throws { + let foreignKeyName = (parentForeignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" + + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER \(Self.insertTriggerName(for: T.self)) + AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN + INSERT INTO \(Metadata.self) + ( + \(quote: Metadata.recordType.name), + \(quote: Metadata.recordName.name), + \(quote: Metadata.zoneName.name), + \(quote: Metadata.ownerName.name), + \(quote: Metadata.parentRecordName.name), + \(quote: Metadata.userModificationDate.name) + ) + SELECT + \(quote: T.tableName, delimiter: .text), + "new".\(quote: T.columns.primaryKey.name), + coalesce( + \(Metadata.zoneName), + \(raw: String.sqliteDataCloudKitSchemaName)_getZoneName(), + \(quote: SyncEngine.defaultZone.zoneID.zoneName, delimiter: .text) + ), + coalesce( + \(Metadata.ownerName), + \(raw: String.sqliteDataCloudKitSchemaName)_getOwnerName(), + \(quote: SyncEngine.defaultZone.zoneID.ownerName, delimiter: .text) + ), + \(raw: foreignKeyName) AS "foreignKeyName", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN \(Metadata.self) ON \(Metadata.recordName) = "foreignKeyName" + ON CONFLICT("recordName") DO NOTHING; + END + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER \(Self.updateTriggerName(for: T.self)) + AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN + UPDATE \(Metadata.self) + SET + "recordName" = "new".\(quote: T.columns.primaryKey.name), + "userModificationDate" = datetime('subsec'), + "parentRecordName" = \(raw: foreignKeyName) + WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name) + ; + END + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER \(Self.deleteTriggerName(for: T.self)) + AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN + DELETE FROM \(Metadata.self) + WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name); + END + """ + ) + .execute(db) + } + + static func dropTriggers( + for _: T.Type, + db: Database + ) throws { + try SQLQueryExpression("DROP TRIGGER \(Self.deleteTriggerName(for: T.self))").execute(db) + try SQLQueryExpression("DROP TRIGGER \(Self.updateTriggerName(for: T.self))").execute(db) + try SQLQueryExpression("DROP TRIGGER \(Self.insertTriggerName(for: T.self))").execute(db) + } + + private static func insertTriggerName( + for _: T.Type + ) -> SQLQueryExpression { + SQLQueryExpression( + #""\#(raw: .sqliteDataCloudKitSchemaName)_\#(raw: T.tableName)_metadataInserts""# + ) } -} + private static func updateTriggerName( + for _: T.Type + ) -> SQLQueryExpression { + SQLQueryExpression( + #""\#(raw: .sqliteDataCloudKitSchemaName)_\#(raw: T.tableName)_metadataUpdates""# + ) + } + + private static func deleteTriggerName( + for _: T.Type + ) -> SQLQueryExpression { + SQLQueryExpression( + #""\#(raw: .sqliteDataCloudKitSchemaName)_\#(raw: T.tableName)_metadataDeletes""# + ) + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift new file mode 100644 index 00000000..c885460f --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift @@ -0,0 +1,63 @@ +import CloudKit + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") +package struct Metadata { + package var recordType: String + package var recordName: String + package var zoneName: String + package var ownerName: String + package var parentRecordName: String? + // @Column(as: CKRecord?.DataRepresentation.self) + package var lastKnownServerRecord: CKRecord? + package var userModificationDate: Date? +} + +// NB: This is generated by inlining the above macro applications. +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table { + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = Metadata + public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) + public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) + public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) + public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) + public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.userModificationDate] + } + } + public static let columns = TableColumns() + public static let tableName = "\(String.sqliteDataCloudKitSchemaName)_metadata" + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let recordType = try decoder.decode(String.self) + let recordName = try decoder.decode(String.self) + let zoneName = try decoder.decode(String.self) + let ownerName = try decoder.decode(String.self) + self.parentRecordName = try decoder.decode(String.self) + let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) + self.userModificationDate = try decoder.decode(Date.self) + guard let recordType else { + throw QueryDecodingError.missingRequiredColumn + } + guard let recordName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let zoneName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let ownerName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let lastKnownServerRecord else { + throw QueryDecodingError.missingRequiredColumn + } + self.recordType = recordType + self.recordName = recordName + self.zoneName = zoneName + self.ownerName = ownerName + self.lastKnownServerRecord = lastKnownServerRecord + } +} + diff --git a/Sources/SharingGRDBCore/CloudKit/RecordType.swift b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift similarity index 94% rename from Sources/SharingGRDBCore/CloudKit/RecordType.swift rename to Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift index 2c78945e..dcaafa73 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordType.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift @@ -1,4 +1,4 @@ -// @Table("\(String.sharingGRDBCloudKitSchemaName)_recordTypes") +// @Table("\(String.sqliteDataCloudKitSchemaName)_recordTypes") package struct RecordType { // @Column(primaryKey: true) package let tableName: String @@ -59,7 +59,7 @@ extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.Primary } } public static let columns = TableColumns() - public static let tableName = "\(String.sharingGRDBCloudKitSchemaName)_recordTypes" + public static let tableName = "\(String.sqliteDataCloudKitSchemaName)_recordTypes" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift similarity index 89% rename from Sources/SharingGRDBCore/CloudKit/StateSerialization.swift rename to Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift index 87c11035..cab04cc0 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift @@ -1,6 +1,6 @@ import CloudKit -// @Table("\(String.sharingGRDBCloudKitSchemaName)_stateSerialization") +// @Table("\(String.sqliteDataCloudKitSchemaName)_stateSerialization") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package struct StateSerialization { package var id = 1 @@ -26,7 +26,7 @@ extension StateSerialization: StructuredQueriesCore.Table { } } public static let columns = TableColumns() - public static let tableName = "\(String.sharingGRDBCloudKitSchemaName)_stateSerialization" + public static let tableName = "\(String.sqliteDataCloudKitSchemaName)_stateSerialization" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { self.id = try decoder.decode(Swift.Int.self) ?? 1 let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 615993e7..fa75f667 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -5,7 +5,7 @@ import OSLog @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final actor SyncEngine { public static nonisolated let defaultZone = CKRecordZone( - zoneName: "co.pointfree.SharingGRDB.defaultZone" + zoneName: "co.pointfree.SQLiteData.defaultZone" ) let database: any DatabaseWriter @@ -19,12 +19,6 @@ public final actor SyncEngine { let defaultSyncEngine: (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol let _container: any Sendable - let operationQueue = { - let queue = OperationQueue() - queue.maxConcurrentOperationCount = 1 - return queue - }() - public init( container: CKContainer, database: any DatabaseWriter, @@ -99,7 +93,7 @@ public final actor SyncEngine { } ) Task { - await withErrorReporting(.sharingGRDBCloudKitFailure) { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await setUpSyncEngine() } } @@ -122,7 +116,7 @@ public final actor SyncEngine { migrator.registerMigration("Create Metadata Tables") { db in try SQLQueryExpression( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sharingGRDBCloudKitSchemaName)_metadata" ( + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( "recordType" TEXT NOT NULL, "recordName" TEXT NOT NULL PRIMARY KEY, "zoneName" TEXT NOT NULL, @@ -138,14 +132,14 @@ public final actor SyncEngine { // TODO: Do we ever query for "parentRecordName"? should we add an index? try SQLQueryExpression( """ - CREATE INDEX IF NOT EXISTS "\(raw: .sharingGRDBCloudKitSchemaName)_metadata_zoneName_ownerName" - ON "\(raw: .sharingGRDBCloudKitSchemaName)_metadata" ("zoneName", "ownerName") + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneName_ownerName" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ("zoneName", "ownerName") """ ) .execute(db) try SQLQueryExpression( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sharingGRDBCloudKitSchemaName)_recordTypes" ( + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( "tableName" TEXT NOT NULL PRIMARY KEY, "schema" TEXT NOT NULL ) STRICT @@ -154,7 +148,7 @@ public final actor SyncEngine { .execute(db) try SQLQueryExpression( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sharingGRDBCloudKitSchemaName)_stateSerialization" ( + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( "id" INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE CHECK ("id" = 1), "data" TEXT NOT NULL ) STRICT @@ -189,21 +183,21 @@ public final actor SyncEngine { if !recordTypesToFetch.isEmpty { // TODO: Should we avoid this unstructured task by making 'setUpSyncEngine' async? Task { - await withErrorReporting(.sharingGRDBCloudKitFailure) { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await metadatabase.write { db in for recordType in recordTypesToFetch { try RecordType.upsert(RecordType.Draft(recordType)).execute(db) } } } - await withErrorReporting(.sharingGRDBCloudKitFailure) { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await underlyingSyncEngine.fetchChanges() } } } try database.write { db in try SQLQueryExpression( - "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sharingGRDBCloudKitSchemaName)" + "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sqliteDataCloudKitSchemaName)" ) .execute(db) db.add(function: .isUpdatingWithServerRecord) @@ -212,59 +206,7 @@ public final actor SyncEngine { db.add(function: .didUpdate(syncEngine: self)) db.add(function: .willDelete(syncEngine: self)) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_inserts" - AFTER INSERT ON \(Metadata.self) - FOR EACH ROW - BEGIN - SELECT - \(raw: String.sharingGRDBCloudKitSchemaName)_didUpdate( - "new"."recordName", - "new"."zoneName", - "new"."ownerName" - ) - WHERE NOT \(raw: String.sharingGRDBCloudKitSchemaName)_isUpdatingWithServerRecord(); - END - """ - ) - .execute(db) - - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_updates" - AFTER UPDATE ON \(Metadata.self) - FOR EACH ROW - BEGIN - SELECT - \(raw: String.sharingGRDBCloudKitSchemaName)_didUpdate( - "new"."recordName", - "new"."zoneName", - "new"."ownerName" - ) - WHERE NOT \(raw: String.sharingGRDBCloudKitSchemaName)_isUpdatingWithServerRecord() - ; - END - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_deletes" - BEFORE DELETE ON \(Metadata.self) - FOR EACH ROW - BEGIN - SELECT - \(raw: String.sharingGRDBCloudKitSchemaName)_willDelete( - "old"."recordName", - "old"."zoneName", - "old"."ownerName" - ) - WHERE NOT \(raw: String.sharingGRDBCloudKitSchemaName)_isUpdatingWithServerRecord(); - END - """ - ) - .execute(db) + try Metadata.createTriggers(tables: tables, db: db) for table in tables { func open(_: T.Type) throws { @@ -283,21 +225,7 @@ public final actor SyncEngine { } try open(table) } - try SQLQueryExpression( - """ - DROP TRIGGER "metadata_deletes" - """ - ).execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER "metadata_updates" - """ - ).execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER "metadata_inserts" - """ - ).execute(db) + try Metadata.dropTriggers(db: db) db.remove(function: .willDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .getOwnerName) @@ -306,7 +234,7 @@ public final actor SyncEngine { } try database.writeWithoutTransaction { db in try SQLQueryExpression( - "DETACH DATABASE \(quote: .sharingGRDBCloudKitSchemaName)" + "DETACH DATABASE \(quote: .sqliteDataCloudKitSchemaName)" ) .execute(db) } @@ -320,11 +248,11 @@ public final actor SyncEngine { public func deleteLocalData() throws { try tearDownSyncEngine() - withErrorReporting(.sharingGRDBCloudKitFailure) { + withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in for table in tables { func open(_: T.Type) { - withErrorReporting(.sharingGRDBCloudKitFailure) { + withErrorReporting(.sqliteDataCloudKitFailure) { try T.delete().execute(db) } } @@ -393,302 +321,25 @@ public final actor SyncEngine { } private func createTriggers(table: T.Type, db: Database) throws { - let from = + let foreignKey = foreignKeysByTableName[T.tableName]?.count(where: \.notnull) == 1 - ? foreignKeysByTableName[T.tableName]?.first(where: \.notnull)?.from + ? foreignKeysByTableName[T.tableName]?.first(where: \.notnull) : nil - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataInserts" - AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN - INSERT INTO \(Metadata.self) - ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") - SELECT - \(quote: T.tableName, delimiter: .text), - "new".\(quote: T.columns.primaryKey.name), - coalesce( - "zoneName", - \(raw: String.sharingGRDBCloudKitSchemaName)_getZoneName(), - \(quote: Self.defaultZone.zoneID.zoneName, delimiter: .text) - ), - coalesce( - "ownerName", - \(raw: String.sharingGRDBCloudKitSchemaName)_getOwnerName(), - \(quote: Self.defaultZone.zoneID.ownerName, delimiter: .text) - ), - \(raw: from.map { #""new"."\#($0)""# } ?? "NULL") AS "foreignKeyName", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "\(raw: String.sharingGRDBCloudKitSchemaName)_metadata" ON "recordName" = "foreignKeyName" - ON CONFLICT("recordName") DO NOTHING; - END - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataUpdates" - AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN - UPDATE \(Metadata.self) - SET - "recordName" = "new".\(quote: T.columns.primaryKey.name), - "userModificationDate" = datetime('subsec'), - "parentRecordName" = \(raw: from.map { #""new"."\#($0)""# } ?? "NULL") - WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name) - ; - END - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataDeletes" - AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN - DELETE FROM \(Metadata.self) - WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name); - END - """ - ) - .execute(db) + try Metadata.createTriggers(for: T.self, parentForeignKey: foreignKey, db: db) let foreignKeys = foreignKeysByTableName[T.tableName] ?? [] for foreignKey in foreignKeys { - switch foreignKey.onDelete { - case .cascade: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - DELETE FROM \(table) - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .restrict: - // TODO: Report issue? - continue - - case .setDefault: - let defaultValue = - try SQLQueryExpression( - """ - SELECT "dflt_value" - FROM pragma_table_info(\(bind: T.tableName)) - WHERE "name" = \(bind: foreignKey.from) - """, - as: String?.self - ) - .fetchOne(db) ?? nil - - guard let defaultValue - else { - // TODO: Report issue? - continue - } - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(table) - SET \(quote: foreignKey.from) = \(raw: defaultValue) - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" - AFTER DELETE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(table) - SET \(quote: foreignKey.from) = NULL - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .noAction: - continue - } - - switch foreignKey.onUpdate { - case .cascade: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" - AFTER UPDATE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(T.self) - SET \(quote: foreignKey.from) = "new".\(quote: foreignKey.to) - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .restrict: - // TODO: Report issue? - continue - - case .setDefault: - let defaultValue = - try SQLQueryExpression( - """ - SELECT "dflt_value" - FROM pragma_table_info(\(bind: T.tableName)) - WHERE "name" = \(bind: foreignKey.from) - """, - as: String?.self - ) - .fetchOne(db) ?? nil - - guard let defaultValue - else { - // TODO: Report issue? - continue - } - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" - AFTER UPDATE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(table) - SET \(quote: foreignKey.from) = \(raw: defaultValue) - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" - AFTER UPDATE ON \(quote: foreignKey.table) - FOR EACH ROW BEGIN - UPDATE \(T.self) - SET \(quote: foreignKey.from) = NULL - WHERE \(quote: foreignKey.from) = "old".\(quote: foreignKey.to); - END - """ - ) - .execute(db) - - case .noAction: - continue - } + try foreignKey.createTriggers(for: T.self, db: db) } } private func dropTriggers(table: T.Type, db: Database) throws { let foreignKeys = foreignKeysByTableName[T.tableName] ?? [] for foreignKey in foreignKeys { - switch foreignKey.onDelete { - case .cascade: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteCascade" - """ - ) - .execute(db) - - case .restrict: - continue - - case .setDefault: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetDefault" - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onDeleteSetNull" - """ - ) - .execute(db) - - case .noAction: - continue - } - - switch foreignKey.onUpdate { - case .cascade: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateCascade" - """ - ) - .execute(db) - - case .restrict: - continue - - case .setDefault: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetDefault" - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: foreignKey.table)_onUpdateSetNull" - """ - ) - .execute(db) - - case .noAction: - continue - } + try foreignKey.dropTriggers(for: T.self, db: db) } - try SQLQueryExpression( - """ - DROP TRIGGER "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataDeletes" - """ - ) - .execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataUpdates" - """ - ) - .execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER "\(raw: .sharingGRDBCloudKitSchemaName)_\(raw: T.tableName)_metadataInserts" - """ - ) - .execute(db) + try Metadata.dropTriggers(for: T.self, db: db) } } @@ -856,7 +507,7 @@ extension SyncEngine: CKSyncEngineDelegate { // TODO: handle this //underlyingSyncEngine.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) for table in tables { - withErrorReporting(.sharingGRDBCloudKitFailure) { + withErrorReporting(.sqliteDataCloudKitFailure) { let names: [String] = try database.read { db in func open(_: T.Type) throws -> [String] { try T @@ -878,7 +529,7 @@ extension SyncEngine: CKSyncEngineDelegate { } } case .signOut, .switchAccounts: - withErrorReporting(.sharingGRDBCloudKitFailure) { + withErrorReporting(.sqliteDataCloudKitFailure) { try deleteLocalData() } @unknown default: @@ -887,7 +538,7 @@ extension SyncEngine: CKSyncEngineDelegate { } private func handleStateUpdate(_ event: CKSyncEngine.Event.StateUpdate) { - withErrorReporting(.sharingGRDBCloudKitFailure) { + withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in try StateSerialization.insert( StateSerialization(data: event.stateSerialization) @@ -900,12 +551,12 @@ extension SyncEngine: CKSyncEngineDelegate { private func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { // TODO: Come back to this once we have zoneName in the metadata table. // $isUpdatingWithServerRecord.withValue(true) { - // withErrorReporting(.sharingGRDBCloudKitFailure) { + // withErrorReporting(.sqliteDataCloudKitFailure) { // try database.write { db in // for deletion in event.deletions { // if let table = tablesByName[deletion.zoneID.zoneName] { // func open(_: T.Type) { - // withErrorReporting(.sharingGRDBCloudKitFailure) { + // withErrorReporting(.sqliteDataCloudKitFailure) { // try T.delete().execute(db) // } // } @@ -933,7 +584,7 @@ extension SyncEngine: CKSyncEngineDelegate { for (recordID, recordType) in deletions { if let table = tablesByName[recordType] { func open(_: T.Type) { - withErrorReporting(.sharingGRDBCloudKitFailure) { + withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in try T.find(recordID: recordID) .delete() @@ -944,7 +595,7 @@ extension SyncEngine: CKSyncEngineDelegate { open(table) } else { reportIssue( - .sharingGRDBCloudKitFailure.appending( + .sqliteDataCloudKitFailure.appending( """ : No table to delete from: "\(recordType)" """ @@ -1014,7 +665,7 @@ extension SyncEngine: CKSyncEngineDelegate { private func mergeFromServerRecord(_ record: CKRecord) { $isUpdatingWithServerRecord.withValue(true) { $currentZoneID.withValue(record.recordID.zoneID) { - withErrorReporting(.sharingGRDBCloudKitFailure) { + withErrorReporting(.sqliteDataCloudKitFailure) { let userModificationDate = try metadatabase.read { db in try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne( @@ -1025,7 +676,7 @@ extension SyncEngine: CKSyncEngineDelegate { guard let table = tablesByName[record.recordType] else { reportIssue( - .sharingGRDBCloudKitFailure.appending( + .sqliteDataCloudKitFailure.appending( """ : No table to merge from: "\(record.recordType)" """ @@ -1093,7 +744,7 @@ extension SyncEngine: CKSyncEngineDelegate { let metadata = metadataFor(recordID: record.recordID) func updateLastKnownServerRecord() { - withErrorReporting(.sharingGRDBCloudKitFailure) { + withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in try Metadata .find(recordID: record.recordID) @@ -1115,7 +766,7 @@ extension SyncEngine: CKSyncEngineDelegate { } private func metadataFor(recordID: CKRecord.ID) -> Metadata? { - withErrorReporting(.sharingGRDBCloudKitFailure) { + withErrorReporting(.sqliteDataCloudKitFailure) { try metadatabase.read { db in try Metadata.find(recordID: recordID).fetchOne(db) } @@ -1148,20 +799,20 @@ extension DatabaseFunction { } fileprivate static var isUpdatingWithServerRecord: Self { - Self(.sharingGRDBCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { + Self(.sqliteDataCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { _ in SharingGRDBCore.isUpdatingWithServerRecord } } fileprivate static var getZoneName: Self { - Self(.sharingGRDBCloudKitSchemaName + "_" + "getZoneName", argumentCount: 0) { _ in + Self(.sqliteDataCloudKitSchemaName + "_" + "getZoneName", argumentCount: 0) { _ in SharingGRDBCore.currentZoneID?.zoneName } } fileprivate static var getOwnerName: Self { - Self(.sharingGRDBCloudKitSchemaName + "_" + "getOwnerName", argumentCount: 0) { _ in + Self(.sqliteDataCloudKitSchemaName + "_" + "getOwnerName", argumentCount: 0) { _ in SharingGRDBCore.currentZoneID?.ownerName } } @@ -1170,7 +821,7 @@ extension DatabaseFunction { _ name: String, function: @escaping @Sendable (String, String, String) async -> Void ) { - self.init(.sharingGRDBCloudKitSchemaName + "_" + name, argumentCount: 3) { arguments in + self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 3) { arguments in guard let recordName = String.fromDatabaseValue(arguments[0]), let zoneName = String.fromDatabaseValue(arguments[1]), @@ -1187,11 +838,12 @@ extension DatabaseFunction { // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates @TaskLocal private var isUpdatingWithServerRecord = false +@available(iOS 16, macOS 13.3, tvOS 16, watchOS 9, *) @TaskLocal private var currentZoneID: CKRecordZone.ID? extension String { - package static let sharingGRDBCloudKitSchemaName = "sqlitedata_icloud" - fileprivate static let sharingGRDBCloudKitFailure = "SharingGRDB CloudKit Failure" + package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" + fileprivate static let sqliteDataCloudKitFailure = "SharingGRDB CloudKit Failure" } @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) @@ -1246,8 +898,7 @@ extension SyncEngine { } modifyOperation.database = container.sharedCloudDatabase - // TODO: can this be container.add? - operationQueue.addOperation(modifyOperation) + container.add(modifyOperation) } return share @@ -1353,7 +1004,6 @@ extension SyncEngine { } container.add(metadataFetchOperation) - //operationQueue.addOperation(operation) operation.qualityOfService = .utility container.add(operation) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index fd0a09d9..d7c8fe0a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -30,7 +30,10 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "users" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "name" TEXT NOT NULL + "name" TEXT NOT NULL, + "parentUserID" TEXT DEFAULT NULL, + + FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE ) STRICT """ ), @@ -39,10 +42,14 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "reminders" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "assignedUserID" TEXT REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + "assignedUserID" TEXT, "title" TEXT NOT NULL, - "parentReminderID" TEXT REFERENCES "reminders"("id") ON DELETE CASCADE ON UPDATE CASCADE, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + "parentReminderID" TEXT, + "remindersListID" TEXT NOT NULL, + + FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ ) @@ -96,7 +103,7 @@ extension BaseCloudKitTests { """ SELECT name FROM pragma_function_list - WHERE name LIKE \(bind: String.sharingGRDBCloudKitSchemaName + "_%") + WHERE name LIKE \(bind: String.sqliteDataCloudKitSchemaName + "_%") """, as: String.self ) @@ -273,4 +280,6 @@ extension BaseCloudKitTests { #expect(metadata == nil) } } + + // TODO: Test what happens when we delete locally and then an edit comes in from the server } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift new file mode 100644 index 00000000..cd874cdd --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -0,0 +1 @@ +// TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 677812f9..4ef5cac5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -62,24 +62,31 @@ extension BaseCloudKitTests { CREATE TRIGGER "sqlitedata_icloud_reminders_metadataInserts" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) SELECT 'reminders', "new"."id", coalesce( - "zoneName", + "sqlitedata_icloud_metadata"."zoneName", sqlitedata_icloud_getZoneName(), - 'co.pointfree.SharingGRDB.defaultZone' + 'co.pointfree.SQLiteData.defaultZone' ), coalesce( - "ownerName", + "sqlitedata_icloud_metadata"."ownerName", sqlitedata_icloud_getOwnerName(), '__defaultOwner__' ), "new"."remindersListID" AS "foreignKeyName", datetime('subsec') FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "recordName" = "foreignKeyName" + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKeyName" ON CONFLICT("recordName") DO NOTHING; END """, @@ -158,24 +165,31 @@ extension BaseCloudKitTests { CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataInserts" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) SELECT 'remindersLists', "new"."id", coalesce( - "zoneName", + "sqlitedata_icloud_metadata"."zoneName", sqlitedata_icloud_getZoneName(), - 'co.pointfree.SharingGRDB.defaultZone' + 'co.pointfree.SQLiteData.defaultZone' ), coalesce( - "ownerName", + "sqlitedata_icloud_metadata"."ownerName", sqlitedata_icloud_getOwnerName(), '__defaultOwner__' ), NULL AS "foreignKeyName", datetime('subsec') FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "recordName" = "foreignKeyName" + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKeyName" ON CONFLICT("recordName") DO NOTHING; END """, @@ -202,24 +216,31 @@ extension BaseCloudKitTests { CREATE TRIGGER "sqlitedata_icloud_users_metadataInserts" AFTER INSERT ON "users" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "zoneName", "ownerName", "parentRecordName", "userModificationDate") + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) SELECT 'users', "new"."id", coalesce( - "zoneName", + "sqlitedata_icloud_metadata"."zoneName", sqlitedata_icloud_getZoneName(), - 'co.pointfree.SharingGRDB.defaultZone' + 'co.pointfree.SQLiteData.defaultZone' ), coalesce( - "ownerName", + "sqlitedata_icloud_metadata"."ownerName", sqlitedata_icloud_getOwnerName(), '__defaultOwner__' ), NULL AS "foreignKeyName", datetime('subsec') FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "recordName" = "foreignKeyName" + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKeyName" ON CONFLICT("recordName") DO NOTHING; END """, @@ -241,6 +262,24 @@ extension BaseCloudKitTests { DELETE FROM "sqlitedata_icloud_metadata" WHERE "recordName" = "old"."id"; END + """, + [18]: """ + CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" + AFTER DELETE ON "users" + FOR EACH ROW BEGIN + UPDATE "users" + SET "parentUserID" = NULL + WHERE "parentUserID" = "old"."id"; + END + """, + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" + AFTER UPDATE ON "users" + FOR EACH ROW BEGIN + UPDATE "users" + SET "parentUserID" = "new"."id" + WHERE "parentUserID" = "old"."id"; + END """ ] """# diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 35e8abb8..f1030898 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -15,6 +15,7 @@ import SharingGRDB @Table struct User: Equatable, Identifiable { let id: UUID var name = "" + var parentUserID: User.ID? } @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) @@ -37,7 +38,10 @@ func database() throws -> DatabasePool { """ CREATE TABLE "users" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "name" TEXT NOT NULL + "name" TEXT NOT NULL, + "parentUserID" TEXT DEFAULT NULL, + + FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE ) STRICT """ ) @@ -46,10 +50,14 @@ func database() throws -> DatabasePool { """ CREATE TABLE "reminders" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "assignedUserID" TEXT REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + "assignedUserID" TEXT, "title" TEXT NOT NULL, - "parentReminderID" TEXT REFERENCES "reminders"("id") ON DELETE CASCADE ON UPDATE CASCADE, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + "parentReminderID" TEXT, + "remindersListID" TEXT NOT NULL, + + FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ ) From 7cd48ddd45c6b7e18f15ae10203e84a3edbebe4f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 15:30:26 -0700 Subject: [PATCH 091/581] more support for foreign key actions --- .../CloudKit/CloudSharingController.swift | 69 +++++++++++ .../SharingGRDBCore/CloudKit/ForeignKey.swift | 72 ++++++++--- .../SharingGRDBCore/CloudKit/Metadata.swift | 28 ++--- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 68 ----------- .../CloudKitTests/CloudKitTests.swift | 2 +- .../CloudKitTests/ForeignKeyTests.swift | 114 +++++++++++++++++- .../CloudKitTests/TriggerTests.swift | 17 +-- Tests/SharingGRDBTests/Internal/Schema.swift | 2 +- 8 files changed, 258 insertions(+), 114 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift b/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift new file mode 100644 index 00000000..b8de71e8 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift @@ -0,0 +1,69 @@ +import CloudKit + +#if canImport(UIKit) +import UIKit +extension UICloudSharingController { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public convenience init(_ record: T) + where T.TableColumns.PrimaryKey == UUID { + // TODO: Remove UUID constraint by reaching into metadata table + // TODO: verify that table has no foreign keys + @Dependency(\.defaultSyncEngine) var syncEngine + let record = try! syncEngine.database.write { db in + return + try Metadata + .find( + recordID: CKRecord.ID.init( + recordName: record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() + ) + ) + .select(\.lastKnownServerRecord) + .fetchOne(db) + } + self.init( + share: CKShare(rootRecord: record!!), + container: syncEngine.container + ) + } +} + +import SwiftUI + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +public struct CloudSharingView: UIViewControllerRepresentable +where T.TableColumns.PrimaryKey == UUID { + let record: T + public init(_ record: T) { + self.record = record + } + + public func makeUIViewController(context: Context) -> UICloudSharingController { + UICloudSharingController(record) + } + + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +public struct CloudSharingView2: UIViewControllerRepresentable { + let share: CKShare + public init(share: CKShare) { + self.share = share + } + + public func makeUIViewController(context: Context) -> UICloudSharingController { + @Dependency(\.defaultSyncEngine) var syncEngine + return UICloudSharingController.init(share: share, container: syncEngine.container) + } + + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } +} +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index d6e3cc80..f78b2a0d 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -72,9 +72,21 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ ) .execute(db) + case .restrict: - // TODO: report issue - break + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteRestrict" + AFTER DELETE ON \(quote: table) + FOR EACH ROW BEGIN + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM \(T.self) + WHERE \(quote: from) = "old".\(quote: to); + END + """ + ) + .execute(db) case .setDefault: let defaultValue = @@ -86,13 +98,8 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """, as: String?.self ) - .fetchOne(db) ?? "NULL" + .fetchOne(db) ?? nil - guard let defaultValue - else { - // TODO: Report issue? - break - } try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER @@ -100,7 +107,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { AFTER DELETE ON \(quote: table) FOR EACH ROW BEGIN UPDATE \(T.self) - SET \(quote: from) = \(raw: defaultValue) + SET \(quote: from) = \(raw: defaultValue ?? "NULL") WHERE \(quote: from) = "old".\(quote: to); END """ @@ -140,9 +147,21 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ ) .execute(db) + case .restrict: - // TODO: report issue - break + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateRestrict" + AFTER UPDATE ON \(quote: table) + FOR EACH ROW BEGIN + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM \(T.self) + WHERE \(quote: from) = "old".\(quote: to); + END + """ + ) + .execute(db) case .setDefault: let defaultValue = @@ -154,13 +173,8 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """, as: String?.self ) - .fetchOne(db) ?? "NULL" + .fetchOne(db) ?? nil - guard let defaultValue - else { - // TODO: Report issue? - break - } try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER @@ -168,7 +182,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { AFTER UPDATE ON \(quote: table) FOR EACH ROW BEGIN UPDATE \(T.self) - SET \(quote: from) = \(raw: defaultValue) + SET \(quote: from) = \(raw: defaultValue ?? "NULL") WHERE \(quote: from) = "old".\(quote: to); END """ @@ -223,7 +237,16 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { ) .execute(db) - case .restrict, .noAction: + case .restrict: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteRestrict" + """ + ) + .execute(db) + + case .noAction: break } @@ -255,7 +278,16 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { ) .execute(db) - case .restrict, .noAction: + case .restrict: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateRestrict" + """ + ) + .execute(db) + + case .noAction: break } } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index d4b29baf..3290d88b 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -9,17 +9,17 @@ extension Metadata { ) throws { try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_inserts" + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_inserts" AFTER INSERT ON \(Metadata.self) FOR EACH ROW BEGIN SELECT - \(raw: String.sqliteDataCloudKitSchemaName)_didUpdate( + \(raw: .sqliteDataCloudKitSchemaName)_didUpdate( "new"."recordName", "new"."zoneName", "new"."ownerName" ) - WHERE NOT \(raw: String.sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord(); + WHERE NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord(); END """ ) @@ -27,17 +27,17 @@ extension Metadata { try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_updates" + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_updates" AFTER UPDATE ON \(Metadata.self) FOR EACH ROW BEGIN SELECT - \(raw: String.sqliteDataCloudKitSchemaName)_didUpdate( + \(raw: .sqliteDataCloudKitSchemaName)_didUpdate( "new"."recordName", "new"."zoneName", "new"."ownerName" ) - WHERE NOT \(raw: String.sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() + WHERE NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() ; END """ @@ -45,17 +45,17 @@ extension Metadata { .execute(db) try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS "metadata_deletes" + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_deletes" BEFORE DELETE ON \(Metadata.self) FOR EACH ROW BEGIN SELECT - \(raw: String.sqliteDataCloudKitSchemaName)_willDelete( + \(raw: .sqliteDataCloudKitSchemaName)_willDelete( "old"."recordName", "old"."zoneName", "old"."ownerName" ) - WHERE NOT \(raw: String.sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord(); + WHERE NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord(); END """ ) @@ -63,9 +63,9 @@ extension Metadata { } static func dropTriggers(db: Database) throws { - try SQLQueryExpression(#"DROP TRIGGER "metadata_deletes""#).execute(db) - try SQLQueryExpression(#"DROP TRIGGER "metadata_updates""#).execute(db) - try SQLQueryExpression(#"DROP TRIGGER "metadata_inserts""#).execute(db) + try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_deletes""#).execute(db) + try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_updates""#).execute(db) + try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_inserts""#).execute(db) } static func createTriggers( @@ -93,12 +93,12 @@ extension Metadata { "new".\(quote: T.columns.primaryKey.name), coalesce( \(Metadata.zoneName), - \(raw: String.sqliteDataCloudKitSchemaName)_getZoneName(), + \(raw: .sqliteDataCloudKitSchemaName)_getZoneName(), \(quote: SyncEngine.defaultZone.zoneID.zoneName, delimiter: .text) ), coalesce( \(Metadata.ownerName), - \(raw: String.sqliteDataCloudKitSchemaName)_getOwnerName(), + \(raw: .sqliteDataCloudKitSchemaName)_getOwnerName(), \(quote: SyncEngine.defaultZone.zoneID.ownerName, delimiter: .text) ), \(raw: foreignKeyName) AS "foreignKeyName", diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index fa75f667..952166ab 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -907,74 +907,6 @@ extension SyncEngine { struct NoCKRecordFound: Error {} -#if canImport(UIKit) - import UIKit - extension UICloudSharingController { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public convenience init(_ record: T) - where T.TableColumns.PrimaryKey == UUID { - // TODO: Remove UUID constraint by reaching into metadata table - // TODO: verify that table has no foreign keys - @Dependency(\.defaultSyncEngine) var syncEngine - let record = try! syncEngine.database.write { db in - return - try Metadata - .find( - recordID: CKRecord.ID.init( - recordName: record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() - ) - ) - .select(\.lastKnownServerRecord) - .fetchOne(db) - } - self.init( - share: CKShare(rootRecord: record!!), - container: syncEngine.container - ) - } - } - - import SwiftUI - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public struct CloudSharingView: UIViewControllerRepresentable - where T.TableColumns.PrimaryKey == UUID { - let record: T - public init(_ record: T) { - self.record = record - } - - public func makeUIViewController(context: Context) -> UICloudSharingController { - UICloudSharingController(record) - } - - public func updateUIViewController( - _ uiViewController: UICloudSharingController, - context: Context - ) { - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public struct CloudSharingView2: UIViewControllerRepresentable { - let share: CKShare - public init(share: CKShare) { - self.share = share - } - - public func makeUIViewController(context: Context) -> UICloudSharingController { - @Dependency(\.defaultSyncEngine) var syncEngine - return UICloudSharingController.init(share: share, container: syncEngine.container) - } - - public func updateUIViewController( - _ uiViewController: UICloudSharingController, - context: Context - ) { - } - } -#endif - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { public nonisolated func userDidAcceptCloudKitShare(with metadata: CKShare.Metadata) { diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index d7c8fe0a..bba7eb2a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -48,7 +48,7 @@ extension BaseCloudKitTests { "remindersListID" TEXT NOT NULL, FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, - FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 5cdfa17c..ca51129b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -64,7 +64,7 @@ extension BaseCloudKitTests { try expectNoDifference( Reminder.all.fetchAll(db), [ - Reminder(id: UUID(3), assignedUserID: nil, title: "Groceries", remindersListID: UUID(2)), + Reminder(id: UUID(3), assignedUserID: nil, title: "Groceries", remindersListID: UUID(2)) ] ) } @@ -99,7 +99,7 @@ extension BaseCloudKitTests { [ Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(9)), Reminder(id: UUID(3), title: "Walk", remindersListID: UUID(9)), - Reminder(id: UUID(4), title: "Haircut", remindersListID: UUID(9)) + Reminder(id: UUID(4), title: "Haircut", remindersListID: UUID(9)), ] ) } @@ -110,5 +110,115 @@ extension BaseCloudKitTests { .saveRecord(CKRecord.ID(UUID(4))), ]) } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRestrict() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)) + } + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(2))), + .saveRecord(CKRecord.ID(UUID(3))), + ]) + do { + let error = #expect(throws: DatabaseError.self) { + try self.database.write { db in + try Reminder.find(UUID(2)).delete().execute(db) + } + } + #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) + try database.read { db in + try expectNoDifference( + Reminder.all.fetchAll(db), + [ + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)), + Reminder( + id: UUID(3), + title: "Milk", + parentReminderID: UUID(2), + remindersListID: UUID(1) + ), + ] + ) + } + } + + do { + let error = #expect(throws: DatabaseError.self) { + try self.database.write { db in + try RemindersList.find(UUID(1)).delete().execute(db) + } + } + #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) + try database.read { db in + try expectNoDifference( + Reminder.all.fetchAll(db), + [ + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)), + Reminder( + id: UUID(3), + title: "Milk", + parentReminderID: UUID(2), + remindersListID: UUID(1) + ), + ] + ) + } + try database.read { db in + try expectNoDifference( + RemindersList.all.fetchAll(db), + [RemindersList(id: UUID(1), title: "Personal")] + ) + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func updateRestrict() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)) + } + } + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(2))), + .saveRecord(CKRecord.ID(UUID(3))), + ]) + + let error = #expect(throws: DatabaseError.self) { + try self.database.write { db in + try Reminder.find(UUID(2)).update { $0.id = UUID(9) }.execute(db) + } + } + #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) + try database.read { db in + try expectNoDifference( + Reminder.all.fetchAll(db), + [ + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)), + Reminder( + id: UUID(3), + title: "Milk", + parentReminderID: UUID(2), + remindersListID: UUID(1) + ), + ] + ) + } + + withKnownIssue("While this extra save is harmless, it would be nice to omit it.") { + underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(2))) + ]) + } + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 4ef5cac5..0fe5c574 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -16,7 +16,7 @@ extension BaseCloudKitTests { #""" [ [0]: """ - CREATE TRIGGER "metadata_inserts" + CREATE TRIGGER "sqlitedata_icloud_metadata_inserts" AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW BEGIN @@ -30,7 +30,7 @@ extension BaseCloudKitTests { END """, [1]: """ - CREATE TRIGGER "metadata_updates" + CREATE TRIGGER "sqlitedata_icloud_metadata_updates" AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW BEGIN @@ -45,7 +45,7 @@ extension BaseCloudKitTests { END """, [2]: """ - CREATE TRIGGER "metadata_deletes" + CREATE TRIGGER "sqlitedata_icloud_metadata_deletes" BEFORE DELETE ON "sqlitedata_icloud_metadata" FOR EACH ROW BEGIN @@ -127,19 +127,20 @@ extension BaseCloudKitTests { END """, [8]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onDeleteCascade" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onDeleteRestrict" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN - DELETE FROM "reminders" + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "reminders" WHERE "parentReminderID" = "old"."id"; END """, [9]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onUpdateCascade" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onUpdateRestrict" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - UPDATE "reminders" - SET "parentReminderID" = "new"."id" + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "reminders" WHERE "parentReminderID" = "old"."id"; END """, diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index f1030898..ade7c4de 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -56,7 +56,7 @@ func database() throws -> DatabasePool { "remindersListID" TEXT NOT NULL, FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, - FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ From 5437161376c8df2c257768f442a54c8973422486 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 16:07:27 -0700 Subject: [PATCH 092/581] two sync engines/ --- .../CloudKit/CKSyncEngineProtocol.swift | 6 +- .../SharingGRDBCore/CloudKit/Logging.swift | 15 +++- .../CloudKit/StateSerializationTable.swift | 70 +++++++++++---- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 89 ++++++++++++------- .../CloudKitTests/ForeignKeyTests.swift | 8 +- 5 files changed, 133 insertions(+), 55 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift index 12adeb87..040245b9 100644 --- a/Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift @@ -1,10 +1,11 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package protocol CKSyncEngineProtocol: Sendable { +package protocol CKSyncEngineProtocol: AnyObject, Sendable { associatedtype State: CKSyncEngineStateProtocol func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws var state: State { get } + var scope: CKDatabase.Scope { get } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngineProtocol { @@ -23,6 +24,9 @@ package protocol CKSyncEngineStateProtocol: Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine: CKSyncEngineProtocol { + package var scope: CKDatabase.Scope { + database.databaseScope + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine.State: CKSyncEngineStateProtocol { diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index d16b2924..d6d924bd 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -3,8 +3,8 @@ import os @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Logger { - func log(_ event: CKSyncEngine.Event) { - let prefix = "handleEvent:" + func log(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) { + let prefix = "[\(syncEngine.database.databaseScope.label)] handleEvent:" switch event { case .stateUpdate: debug("\(prefix) stateUpdate") @@ -227,3 +227,14 @@ extension Logger { } } } + +extension CKDatabase.Scope { + var label: String { + switch self { + case .public: "public" + case .private: "private" + case .shared: "shared" + @unknown default: "unknown" + } + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift index cab04cc0..23f5d281 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift @@ -1,38 +1,76 @@ import CloudKit +import StructuredQueries // @Table("\(String.sqliteDataCloudKitSchemaName)_stateSerialization") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package struct StateSerialization { - package var id = 1 + // @Column(primaryKey: true) + package var scope: CKDatabase.Scope // @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) package var data: CKSyncEngine.State.Serialization } -// NB: This is generated by inlining the above macro applications. -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension StateSerialization: StructuredQueriesCore.Table { - public struct TableColumns: StructuredQueriesCore.TableDefinition { +extension CKDatabase.Scope: @retroactive QueryBindable { +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { public typealias QueryValue = StateSerialization - public let id = StructuredQueriesCore.TableColumn( - "id", - keyPath: \QueryValue.id, - default: 1 - ) - public let data = StructuredQueriesCore.TableColumn< - QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation - >("data", keyPath: \QueryValue.data) + public let scope = StructuredQueriesCore.TableColumn("scope", keyPath: \QueryValue.scope) + public let data = StructuredQueriesCore.TableColumn("data", keyPath: \QueryValue.data) + public var primaryKey: StructuredQueriesCore.TableColumn { + self.scope + } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.id, QueryValue.columns.data] + [QueryValue.columns.scope, QueryValue.columns.data] + } + } + public struct Draft: StructuredQueriesCore.TableDraft { + public typealias PrimaryTable = StateSerialization + package var scope: CKDatabase.Scope? + package var data: CKSyncEngine.State.Serialization + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = StateSerialization.Draft + public let scope = StructuredQueriesCore.TableColumn("scope", keyPath: \QueryValue.scope) + public let data = StructuredQueriesCore.TableColumn("data", keyPath: \QueryValue.data) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.scope, QueryValue.columns.data] + } + } + public static let columns = TableColumns() + public static let tableName = StateSerialization.tableName + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + self.scope = try decoder.decode(CKDatabase.Scope.self) + let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) + guard let data else { + throw QueryDecodingError.missingRequiredColumn + } + self.data = data + } + public init(_ other: StateSerialization) { + self.scope = other.scope + self.data = other.data + } + public init( + scope: CKDatabase.Scope? = nil, + data: CKSyncEngine.State.Serialization + ) { + self.scope = scope + self.data = data } } public static let columns = TableColumns() - public static let tableName = "\(String.sqliteDataCloudKitSchemaName)_stateSerialization" + public static let tableName = "sqlitedata_icloud_stateSerialization" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - self.id = try decoder.decode(Swift.Int.self) ?? 1 + let scope = try decoder.decode(CKDatabase.Scope.self) let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) + guard let scope else { + throw QueryDecodingError.missingRequiredColumn + } guard let data else { throw QueryDecodingError.missingRequiredColumn } + self.scope = scope self.data = data } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 952166ab..62ae54de 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -15,8 +15,11 @@ public final actor SyncEngine { let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] fileprivate let foreignKeysByTableName: [String: [ForeignKey]] - var underlyingSyncEngine: (any CKSyncEngineProtocol)! - let defaultSyncEngine: (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol + var privateSyncEngine: (any CKSyncEngineProtocol)! + var sharedSyncEngine: (any CKSyncEngineProtocol)! + let defaultSyncEngines: + (any DatabaseReader, SyncEngine) + -> (private: any CKSyncEngineProtocol, shared: any CKSyncEngineProtocol) let _container: any Sendable public init( @@ -27,14 +30,25 @@ public final actor SyncEngine { ) throws { try self.init( container: container, - defaultSyncEngine: { database, syncEngine in - CKSyncEngine( - CKSyncEngine.Configuration( - database: container.sharedCloudDatabase, - stateSerialization: try? database.read { db in // TODO: write test for this - try StateSerialization.select(\.data).fetchOne(db) - }, - delegate: syncEngine + defaultSyncEngines: { database, syncEngine in + ( + private: CKSyncEngine( + CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: try? database.read { db in // TODO: write test for this + try StateSerialization.find(.private).select(\.data).fetchOne(db) + }, + delegate: syncEngine + ) + ), + shared: CKSyncEngine( + CKSyncEngine.Configuration( + database: container.sharedCloudDatabase, + stateSerialization: try? database.read { db in // TODO: write test for this + try StateSerialization.find(.shared).select(\.data).fetchOne(db) + }, + delegate: syncEngine + ) ) ) }, @@ -52,7 +66,7 @@ public final actor SyncEngine { tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) throws { try self.init( - defaultSyncEngine: { _, _ in defaultSyncEngine }, + defaultSyncEngines: { _, _ in (defaultSyncEngine, defaultSyncEngine) }, database: database, logger: Logger(.disabled), metadatabaseURL: metadatabaseURL, @@ -62,7 +76,10 @@ public final actor SyncEngine { private init( container: (any Sendable)? = Void?.none, - defaultSyncEngine: @escaping (any DatabaseReader, SyncEngine) -> any CKSyncEngineProtocol, + defaultSyncEngines: @escaping ( + any DatabaseReader, + SyncEngine + ) -> (private: any CKSyncEngineProtocol, shared: any CKSyncEngineProtocol), database: any DatabaseWriter, logger: Logger, metadatabaseURL: URL, @@ -76,7 +93,7 @@ public final actor SyncEngine { """ ) self._container = container - self.defaultSyncEngine = defaultSyncEngine + self.defaultSyncEngines = defaultSyncEngines self.database = database self.logger = logger self.metadatabaseURL = metadatabaseURL @@ -104,7 +121,9 @@ public final actor SyncEngine { } package func setUpSyncEngine() throws { - defer { underlyingSyncEngine = defaultSyncEngine(metadatabase, self) } + defer { + (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) + } metadatabase = try defaultMetadatabase // TODO: go towards idempotent migrations instead of GRDB migrator by the end of all of this @@ -149,7 +168,7 @@ public final actor SyncEngine { try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( - "id" INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE CHECK ("id" = 1), + "scope" TEXT NOT NULL PRIMARY KEY, "data" TEXT NOT NULL ) STRICT """ @@ -191,7 +210,7 @@ public final actor SyncEngine { } } await withErrorReporting(.sqliteDataCloudKitFailure) { - try await underlyingSyncEngine.fetchChanges() + try await privateSyncEngine.fetchChanges() } } } @@ -243,7 +262,8 @@ public final actor SyncEngine { } public func fetchChanges() async throws { - try await underlyingSyncEngine.fetchChanges() + try await privateSyncEngine.fetchChanges() + try await sharedSyncEngine.fetchChanges() } public func deleteLocalData() throws { @@ -264,7 +284,7 @@ public final actor SyncEngine { } func didUpdate(recordName: String, zoneName: String, ownerName: String) { - underlyingSyncEngine.state.add( + privateSyncEngine.state.add( pendingRecordZoneChanges: [ .saveRecord( CKRecord.ID( @@ -280,7 +300,7 @@ public final actor SyncEngine { } func willDelete(recordName: String, zoneName: String, ownerName: String) { - underlyingSyncEngine.state.add( + privateSyncEngine.state.add( pendingRecordZoneChanges: [ .deleteRecord( CKRecord.ID( @@ -346,13 +366,13 @@ public final actor SyncEngine { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - logger.log(event) + logger.log(event, syncEngine: syncEngine) switch event { case .accountChange(let event): handleAccountChange(event) case .stateUpdate(let event): - handleStateUpdate(event) + handleStateUpdate(event, syncEngine: syncEngine) case .fetchedDatabaseChanges(let event): handleFetchedDatabaseChanges(event) case .sentDatabaseChanges: @@ -423,7 +443,7 @@ extension SyncEngine: CKSyncEngineDelegate { .joined(separator: ", ") logger.debug( """ - nextRecordZoneChangeBatch: \(context.reason) + [\(syncEngine.scope.label)] nextRecordZoneChangeBatch: \(context.reason) \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") @@ -504,8 +524,7 @@ extension SyncEngine: CKSyncEngineDelegate { private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { switch event.changeType { case .signIn: - // TODO: handle this - //underlyingSyncEngine.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) + privateSyncEngine.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) for table in tables { withErrorReporting(.sqliteDataCloudKitFailure) { let names: [String] = try database.read { db in @@ -516,7 +535,7 @@ extension SyncEngine: CKSyncEngineDelegate { } return try open(table) } - underlyingSyncEngine.state.add( + privateSyncEngine.state.add( pendingRecordZoneChanges: names.map { .saveRecord( CKRecord.ID( @@ -537,11 +556,17 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func handleStateUpdate(_ event: CKSyncEngine.Event.StateUpdate) { + private func handleStateUpdate( + _ event: CKSyncEngine.Event.StateUpdate, + syncEngine: CKSyncEngine + ) { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in - try StateSerialization.insert( - StateSerialization(data: event.stateSerialization) + try StateSerialization.upsert( + StateSerialization.Draft( + scope: syncEngine.database.databaseScope, + data: event.stateSerialization + ) ) .execute(db) } @@ -614,8 +639,8 @@ extension SyncEngine: CKSyncEngineDelegate { var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] defer { - underlyingSyncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) - underlyingSyncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + privateSyncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + privateSyncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } for failedRecordSave in event.failedRecordSaves { let failedRecord = failedRecordSave.record @@ -838,7 +863,7 @@ extension DatabaseFunction { // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates @TaskLocal private var isUpdatingWithServerRecord = false -@available(iOS 16, macOS 13.3, tvOS 16, watchOS 9, *) +@available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9, *) @TaskLocal private var currentZoneID: CKRecordZone.ID? extension String { @@ -918,7 +943,7 @@ extension SyncEngine { guard let self else { return } Task { await withErrorReporting { - try await self.underlyingSyncEngine + try await self.privateSyncEngine .fetchChanges( .init( scope: .zoneIDs([metadata.hierarchicalRootRecordID!.zoneID]), diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index ca51129b..c478541c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -214,10 +214,10 @@ extension BaseCloudKitTests { ) } - withKnownIssue("While this extra save is harmless, it would be nice to omit it.") { - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(2))) - ]) + + withKnownIssue("We would prefer that no '.savedRecord's are appended.") { + // NB: A '.savedRecord(UUID(9))' is being enqueued. + underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) } } } From 983076a3278be1a6dae12d7c88388ae42b595bef Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 30 May 2025 16:10:36 -0700 Subject: [PATCH 093/581] wip --- Sources/SharingGRDBCore/CloudKit/ForeignKey.swift | 2 +- Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index f78b2a0d..f0f01fc1 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -1,4 +1,4 @@ -import StructuredQueries +import StructuredQueriesCore struct ForeignKey: QueryDecodable, QueryRepresentable { typealias QueryValue = Self diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift index 23f5d281..80c1a9e7 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift @@ -1,5 +1,5 @@ import CloudKit -import StructuredQueries +import StructuredQueriesCore // @Table("\(String.sqliteDataCloudKitSchemaName)_stateSerialization") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From 37e7e9de5870e5aa625c9efa8e8bf88cfe20c7d5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 16:16:59 -0700 Subject: [PATCH 094/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 62ae54de..8cb67fe0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -922,8 +922,8 @@ extension SyncEngine { } } - modifyOperation.database = container.sharedCloudDatabase - container.add(modifyOperation) + modifyOperation.database = container.privateCloudDatabase + container.privateCloudDatabase.add(modifyOperation) } return share From 4ac088bedd088c9e37101b615ee618a9021f5e9b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 30 May 2025 16:17:59 -0700 Subject: [PATCH 095/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 8cb67fe0..291d6172 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -943,7 +943,7 @@ extension SyncEngine { guard let self else { return } Task { await withErrorReporting { - try await self.privateSyncEngine + try await self.sharedSyncEngine .fetchChanges( .init( scope: .zoneIDs([metadata.hierarchicalRootRecordID!.zoneID]), From 96408005c63e4439f68e185e2922b2f47303302d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 30 May 2025 16:23:04 -0700 Subject: [PATCH 096/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 291d6172..5cf64fad 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -284,7 +284,10 @@ public final actor SyncEngine { } func didUpdate(recordName: String, zoneName: String, ownerName: String) { - privateSyncEngine.state.add( + let syncEngine = ownerName == Self.defaultZone.zoneID.ownerName + ? privateSyncEngine + : sharedSyncEngine + syncEngine?.state.add( pendingRecordZoneChanges: [ .saveRecord( CKRecord.ID( @@ -300,7 +303,10 @@ public final actor SyncEngine { } func willDelete(recordName: String, zoneName: String, ownerName: String) { - privateSyncEngine.state.add( + let syncEngine = ownerName == Self.defaultZone.zoneID.ownerName + ? privateSyncEngine + : sharedSyncEngine + syncEngine?.state.add( pendingRecordZoneChanges: [ .deleteRecord( CKRecord.ID( @@ -383,7 +389,7 @@ extension SyncEngine: CKSyncEngineDelegate { deletions: event.deletions.map { ($0.recordID, $0.recordType) } ) case .sentRecordZoneChanges(let event): - handleSentRecordZoneChanges(event) + handleSentRecordZoneChanges(event, syncEngine: syncEngine) case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, .didFetchChanges, .willSendChanges, .didSendChanges: break @@ -631,7 +637,10 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func handleSentRecordZoneChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) { + private func handleSentRecordZoneChanges( + _ event: CKSyncEngine.Event.SentRecordZoneChanges, + syncEngine: CKSyncEngine + ) { for savedRecord in event.savedRecords { refreshLastKnownServerRecord(savedRecord) } @@ -639,8 +648,8 @@ extension SyncEngine: CKSyncEngineDelegate { var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] defer { - privateSyncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) - privateSyncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } for failedRecordSave in event.failedRecordSaves { let failedRecord = failedRecordSave.record From d457e1504176040be04bcead558fe0cdec407df4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 16:23:07 -0700 Subject: [PATCH 097/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5cf64fad..d4263ba1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -210,7 +210,7 @@ public final actor SyncEngine { } } await withErrorReporting(.sqliteDataCloudKitFailure) { - try await privateSyncEngine.fetchChanges() + try await fetchChanges() } } } From bdc1d824e598075a907d68b18809d7657104f57f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 17:00:27 -0700 Subject: [PATCH 098/581] wip --- Examples/Reminders/RemindersApp.swift | 2 +- Examples/Reminders/Schema.swift | 5 +--- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 25 ++++++++++--------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index ddc15078..650eae6c 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -68,6 +68,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata ) { @Dependency(\.defaultSyncEngine) var syncEngine - syncEngine.userDidAcceptCloudKitShare(with: cloudKitShareMetadata) + syncEngine.acceptShare(metadata: cloudKitShareMetadata) } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 987ba498..6ea42ce9 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -116,6 +116,7 @@ func appDatabase() throws -> any DatabaseWriter { } var migrator = DatabaseMigrator() #if DEBUG + // TODO: should we warn against this for CK apps? migrator.eraseDatabaseOnSchemaChange = true #endif @@ -230,10 +231,6 @@ func appDatabase() throws -> any DatabaseWriter { .execute(db) } - migrator.registerMigration("foo") { db in - try #sql("alter table tags add column hello text").execute(db) - } - #if DEBUG && targetEnvironment(simulator) if context == .preview { migrator.registerMigration("Seed sample data") { db in diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d4263ba1..a5088de3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -261,6 +261,8 @@ public final actor SyncEngine { try FileManager.default.removeItem(at: metadatabaseURL) } + // TODO: resendAll() ? + public func fetchChanges() async throws { try await privateSyncEngine.fetchChanges() try await sharedSyncEngine.fetchChanges() @@ -891,11 +893,18 @@ extension URL { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { + public struct CantShareRecordWithParent: Error {} + public func share( record: T, configure: @Sendable (CKShare) -> Void ) async throws -> CKShare where T.TableColumns.PrimaryKey == UUID { + guard foreignKeysByTableName[T.tableName]?.count(where: \.notnull) ?? 0 == 0 + else { + throw CantShareRecordWithParent() + } + let recordName = record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() let lastKnownServerRecord = try await database.write { db in @@ -943,11 +952,8 @@ struct NoCKRecordFound: Error {} @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { - public nonisolated func userDidAcceptCloudKitShare(with metadata: CKShare.Metadata) { + public nonisolated func acceptShare(metadata: CKShare.Metadata) { let operation = CKAcceptSharesOperation(shareMetadatas: [metadata]) - operation.perShareResultBlock = { metadata, result in - print(metadata.hierarchicalRootRecordID) - } operation.acceptSharesResultBlock = { [weak self] result in guard let self else { return } Task { @@ -962,15 +968,10 @@ extension SyncEngine { } } } - - let metadataFetchOperation = CKFetchShareMetadataOperation(shareURLs: [metadata.share.url!]) - metadataFetchOperation.shouldFetchRootRecord = true - metadataFetchOperation.perShareMetadataResultBlock = { url, result in - //print("!!!") - } - container.add(metadataFetchOperation) - operation.qualityOfService = .utility container.add(operation) } } + +// TODO: Handle: SharingGRDB CloudKit Failure: No table to delete from: "cloudkit.share" +// TODO: what kind of APIs do we need to expose for people to query for shared info? participants From bbb12c208d9af9f1ea9f02bb3c9d7809b2a115d0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 20:35:55 -0700 Subject: [PATCH 099/581] wip --- Examples/Reminders/RemindersApp.swift | 4 +- Examples/Reminders/RemindersDetail.swift | 2 +- .../CloudKit/CloudSharingController.swift | 99 ++++++++++--------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 80 +++++---------- .../CloudKit/SyncEngineProtocol+Live.swift | 27 +++++ ...rotocol.swift => SyncEngineProtocol.swift} | 33 ++++--- .../CloudKitTests/CloudKitTests.swift | 17 ++-- .../CloudKitTests/ForeignKeyTests.swift | 18 ++-- .../CloudKitTests/SharingTests.swift | 18 +++- .../CloudKitTests/TriggerTests.swift | 3 +- .../Internal/BaseCloudKitTests.swift | 35 +++++-- .../Internal/CloudKitTestHelpers.swift | 33 ++++++- 12 files changed, 221 insertions(+), 148 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift rename Sources/SharingGRDBCore/CloudKit/{CKSyncEngineProtocol.swift => SyncEngineProtocol.swift} (54%) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 650eae6c..7d28766f 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -68,6 +68,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata ) { @Dependency(\.defaultSyncEngine) var syncEngine - syncEngine.acceptShare(metadata: cloudKitShareMetadata) + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } } } diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 7b5ffa28..55cce2f8 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -151,7 +151,7 @@ struct RemindersDetailView: View { private func shareButtonTapped(remindersList: RemindersList) { Task { await withErrorReporting { - presentedShare = try await syncEngine.share(record: remindersList) { + presentedShare = try await syncEngine.createShare(record: remindersList) { $0[CKShare.SystemFieldKey.title] = remindersList.title as CKRecordValue } } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift b/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift index b8de71e8..2b7ffe5c 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift @@ -1,53 +1,55 @@ import CloudKit - -#if canImport(UIKit) -import UIKit -extension UICloudSharingController { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public convenience init(_ record: T) - where T.TableColumns.PrimaryKey == UUID { - // TODO: Remove UUID constraint by reaching into metadata table - // TODO: verify that table has no foreign keys - @Dependency(\.defaultSyncEngine) var syncEngine - let record = try! syncEngine.database.write { db in - return - try Metadata - .find( - recordID: CKRecord.ID.init( - recordName: record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() - ) - ) - .select(\.lastKnownServerRecord) - .fetchOne(db) - } - self.init( - share: CKShare(rootRecord: record!!), - container: syncEngine.container - ) - } -} - import SwiftUI +import UIKit -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -public struct CloudSharingView: UIViewControllerRepresentable -where T.TableColumns.PrimaryKey == UUID { - let record: T - public init(_ record: T) { - self.record = record - } - - public func makeUIViewController(context: Context) -> UICloudSharingController { - UICloudSharingController(record) - } - - public func updateUIViewController( - _ uiViewController: UICloudSharingController, - context: Context - ) { - } -} - +#if canImport(UIKit) +//import UIKit +//extension UICloudSharingController { +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// public convenience init(_ record: T) +// where T.TableColumns.PrimaryKey == UUID { +// // TODO: Remove UUID constraint by reaching into metadata table +// // TODO: verify that table has no foreign keys +// @Dependency(\.defaultSyncEngine) var syncEngine +// let record = try! syncEngine.database.write { db in +// return +// try Metadata +// .find( +// recordID: CKRecord.ID.init( +// recordName: record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() +// ) +// ) +// .select(\.lastKnownServerRecord) +// .fetchOne(db) +// } +// self.init( +// share: CKShare(rootRecord: record!!), +// container: syncEngine.container +// ) +// } +//} +// +//import SwiftUI +// +//@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +//public struct CloudSharingView: UIViewControllerRepresentable +//where T.TableColumns.PrimaryKey == UUID { +// let record: T +// public init(_ record: T) { +// self.record = record +// } +// +// public func makeUIViewController(context: Context) -> UICloudSharingController { +// UICloudSharingController(record) +// } +// +// public func updateUIViewController( +// _ uiViewController: UICloudSharingController, +// context: Context +// ) { +// } +//} +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public struct CloudSharingView2: UIViewControllerRepresentable { let share: CKShare @@ -56,8 +58,9 @@ public struct CloudSharingView2: UIViewControllerRepresentable { } public func makeUIViewController(context: Context) -> UICloudSharingController { + // TODO: Should we take the container from the sync engine or should we require it to be passed in? @Dependency(\.defaultSyncEngine) var syncEngine - return UICloudSharingController.init(share: share, container: syncEngine.container) + return UICloudSharingController(share: share, container: syncEngine.container) } public func updateUIViewController( diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index a5088de3..2c5a6ad1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -15,11 +15,11 @@ public final actor SyncEngine { let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] fileprivate let foreignKeysByTableName: [String: [ForeignKey]] - var privateSyncEngine: (any CKSyncEngineProtocol)! - var sharedSyncEngine: (any CKSyncEngineProtocol)! + var privateSyncEngine: (any SyncEngineProtocol)! + var sharedSyncEngine: (any SyncEngineProtocol)! let defaultSyncEngines: (any DatabaseReader, SyncEngine) - -> (private: any CKSyncEngineProtocol, shared: any CKSyncEngineProtocol) + -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) let _container: any Sendable public init( @@ -60,13 +60,14 @@ public final actor SyncEngine { } package init( - defaultSyncEngine: any CKSyncEngineProtocol, + privateSyncEngine: any SyncEngineProtocol, + sharedSyncEngine: any SyncEngineProtocol, database: any DatabaseWriter, metadatabaseURL: URL, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) throws { try self.init( - defaultSyncEngines: { _, _ in (defaultSyncEngine, defaultSyncEngine) }, + defaultSyncEngines: { _, _ in (privateSyncEngine, sharedSyncEngine) }, database: database, logger: Logger(.disabled), metadatabaseURL: metadatabaseURL, @@ -79,7 +80,7 @@ public final actor SyncEngine { defaultSyncEngines: @escaping ( any DatabaseReader, SyncEngine - ) -> (private: any CKSyncEngineProtocol, shared: any CKSyncEngineProtocol), + ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), database: any DatabaseWriter, logger: Logger, metadatabaseURL: URL, @@ -133,6 +134,7 @@ public final actor SyncEngine { migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create Metadata Tables") { db in + // TODO: Should "recordName" be "collate no case"? try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( @@ -286,7 +288,8 @@ public final actor SyncEngine { } func didUpdate(recordName: String, zoneName: String, ownerName: String) { - let syncEngine = ownerName == Self.defaultZone.zoneID.ownerName + let syncEngine = + ownerName == Self.defaultZone.zoneID.ownerName ? privateSyncEngine : sharedSyncEngine syncEngine?.state.add( @@ -305,7 +308,8 @@ public final actor SyncEngine { } func willDelete(recordName: String, zoneName: String, ownerName: String) { - let syncEngine = ownerName == Self.defaultZone.zoneID.ownerName + let syncEngine = + ownerName == Self.defaultZone.zoneID.ownerName ? privateSyncEngine : sharedSyncEngine syncEngine?.state.add( @@ -894,8 +898,9 @@ extension URL { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { public struct CantShareRecordWithParent: Error {} + public struct NoCKRecordFound: Error {} - public func share( + public func createShare( record: T, configure: @Sendable (CKShare) -> Void ) async throws -> CKShare @@ -907,12 +912,12 @@ extension SyncEngine { let recordName = record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() let lastKnownServerRecord = - try await database.write { db in - try Metadata - .find(recordID: CKRecord.ID(recordName: recordName)) - .select(\.lastKnownServerRecord) - .fetchOne(db) - } ?? nil + try await database.write { db in + try Metadata + .find(recordID: CKRecord.ID(recordName: recordName)) + .select(\.lastKnownServerRecord) + .fetchOne(db) + } ?? nil guard let lastKnownServerRecord else { @@ -925,51 +930,16 @@ extension SyncEngine { ) let share = CKShare(rootRecord: lastKnownServerRecord, shareID: shareID) configure(share) - - let modifyOperation = CKModifyRecordsOperation( - recordsToSave: [share, lastKnownServerRecord], - recordIDsToDelete: nil + _ = try await container.privateCloudDatabase.modifyRecords( + saving: [share, lastKnownServerRecord], + deleting: [] ) - try await withUnsafeThrowingContinuation { - (continuation: UnsafeContinuation) in - modifyOperation.modifyRecordsCompletionBlock = { records, recordIDs, error in - if let error = error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - - modifyOperation.database = container.privateCloudDatabase - container.privateCloudDatabase.add(modifyOperation) - } return share } -} - -struct NoCKRecordFound: Error {} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine { - public nonisolated func acceptShare(metadata: CKShare.Metadata) { - let operation = CKAcceptSharesOperation(shareMetadatas: [metadata]) - operation.acceptSharesResultBlock = { [weak self] result in - guard let self else { return } - Task { - await withErrorReporting { - try await self.sharedSyncEngine - .fetchChanges( - .init( - scope: .zoneIDs([metadata.hierarchicalRootRecordID!.zoneID]), - operationGroup: nil - ) - ) - } - } - } - operation.qualityOfService = .utility - container.add(operation) + public func acceptShare(metadata: CKShare.Metadata) async throws { + try await sharedSyncEngine.acceptShare(metadata: ShareMetadata(rawValue: metadata)) } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift new file mode 100644 index 00000000..7bd17b37 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift @@ -0,0 +1,27 @@ +import CloudKit + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKSyncEngine: SyncEngineProtocol { + package var scope: CKDatabase.Scope { + database.databaseScope + } + + package func acceptShare(metadata: ShareMetadata) async throws { + guard let metadata = metadata.rawValue + else { + reportIssue("TODO") + return + } + let container = CKContainer(identifier: metadata.containerIdentifier) + try await container.accept(metadata) + try await fetchChanges( + .init( + scope: .zoneIDs([metadata.hierarchicalRootRecordID!.zoneID]), + operationGroup: nil + ) + ) + } +} +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKSyncEngine.State: CKSyncEngineStateProtocol { +} diff --git a/Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift similarity index 54% rename from Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift rename to Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 040245b9..5f03c08a 100644 --- a/Sources/SharingGRDBCore/CloudKit/CKSyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -1,19 +1,38 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package protocol CKSyncEngineProtocol: AnyObject, Sendable { +package protocol SyncEngineProtocol: AnyObject, Sendable { associatedtype State: CKSyncEngineStateProtocol func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws var state: State { get } var scope: CKDatabase.Scope { get } + func acceptShare(metadata: ShareMetadata) async throws } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngineProtocol { +extension SyncEngineProtocol { package func fetchChanges() async throws { try await fetchChanges(CKSyncEngine.FetchChangesOptions()) } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package struct ShareMetadata: Hashable { + package var containerIdentifier: String + package var hierarchicalRootRecordID: CKRecord.ID? + package var rawValue: CKShare.Metadata? + package init(rawValue: CKShare.Metadata) { + self.containerIdentifier = rawValue.containerIdentifier + self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID + self.rawValue = rawValue + } + package init(containerIdentifier: String, hierarchicalRootRecordID: CKRecord.ID?) { + self.containerIdentifier = containerIdentifier + self.hierarchicalRootRecordID = hierarchicalRootRecordID + self.rawValue = nil + } +} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package protocol CKSyncEngineStateProtocol: Sendable { func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) @@ -21,13 +40,3 @@ package protocol CKSyncEngineStateProtocol: Sendable { func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) } - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngine: CKSyncEngineProtocol { - package var scope: CKDatabase.Scope { - database.databaseScope - } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngine.State: CKSyncEngineStateProtocol { -} diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index bba7eb2a..c6b2233d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -64,14 +64,15 @@ extension BaseCloudKitTests { try await syncEngine.setUpSyncEngine() // TODO: it would be nice if `setUpSyncEngine` was async try await Task.sleep(for: .seconds(0.1)) - underlyingSyncEngine.assertFetchChangesScopes([.all]) + privateSyncEngine.assertFetchChangesScopes([.all]) + sharedSyncEngine.assertFetchChangesScopes([.all]) try await database.write { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))) ]) @@ -145,7 +146,7 @@ extension BaseCloudKitTests { .insert(RemindersList(id: UUID(1), title: "Personal")) .execute(db) } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))) ]) try database.write { db in @@ -154,7 +155,7 @@ extension BaseCloudKitTests { .update { $0.title = "Work" } .execute(db) } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))) ]) try database.write { db in @@ -163,7 +164,7 @@ extension BaseCloudKitTests { .delete() .execute(db) } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .deleteRecord(CKRecord.ID(UUID(1))) ]) } @@ -175,7 +176,7 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))) ]) @@ -216,7 +217,7 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))) ]) let record = CKRecord( @@ -258,7 +259,7 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))) ]) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index c478541c..03ca6b08 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -18,7 +18,7 @@ extension BaseCloudKitTests { Reminder(id: UUID(3), title: "Haircut", remindersListID: UUID(1)) } } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(2))), @@ -30,7 +30,7 @@ extension BaseCloudKitTests { try database.read { db in try #expect(Reminder.all.fetchAll(db) == []) } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .deleteRecord(CKRecord.ID(UUID(1))), .deleteRecord(CKRecord.ID(UUID(1))), .deleteRecord(CKRecord.ID(UUID(2))), @@ -52,7 +52,7 @@ extension BaseCloudKitTests { ) } } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(2))), .saveRecord(CKRecord.ID(UUID(3))), @@ -68,7 +68,7 @@ extension BaseCloudKitTests { ] ) } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .deleteRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(3))), ]) @@ -84,7 +84,7 @@ extension BaseCloudKitTests { Reminder(id: UUID(4), title: "Haircut", remindersListID: UUID(1)) } } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(2))), .saveRecord(CKRecord.ID(UUID(3))), @@ -103,7 +103,7 @@ extension BaseCloudKitTests { ] ) } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(9))), .saveRecord(CKRecord.ID(UUID(2))), .saveRecord(CKRecord.ID(UUID(3))), @@ -120,7 +120,7 @@ extension BaseCloudKitTests { Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)) } } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(2))), .saveRecord(CKRecord.ID(UUID(3))), @@ -187,7 +187,7 @@ extension BaseCloudKitTests { Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)) } } - underlyingSyncEngine.state.assertPendingRecordZoneChanges([ + privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(1))), .saveRecord(CKRecord.ID(UUID(2))), .saveRecord(CKRecord.ID(UUID(3))), @@ -217,7 +217,7 @@ extension BaseCloudKitTests { withKnownIssue("We would prefer that no '.savedRecord's are appended.") { // NB: A '.savedRecord(UUID(9))' is being enqueued. - underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) + privateSyncEngine.state.assertPendingRecordZoneChanges([]) } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index cd874cdd..57e82492 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -1 +1,17 @@ -// TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + final class SharingTests: BaseCloudKitTests, @unchecked Sendable { + @Test func basics() { + + } + } +} + + // TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 0fe5c574..5023519d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -298,7 +298,8 @@ extension BaseCloudKitTests { try await syncEngine.setUpSyncEngine() try await Task.sleep(for: .seconds(0.1)) - underlyingSyncEngine.assertFetchChangesScopes([.all]) + privateSyncEngine.assertFetchChangesScopes([.all]) + sharedSyncEngine.assertFetchChangesScopes([.all]) let triggersAfterReSetUp = try await database.write { db in try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 5b7325ac..2b793c6e 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,8 @@ import Testing class BaseCloudKitTests: @unchecked Sendable { let database: any DatabaseWriter private let _syncEngine: any Sendable - private let _underlyingSyncEngine: any Sendable + private let _privateSyncEngine: any Sendable + private let _sharedSyncEngine: any Sendable @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var syncEngine: SyncEngine { @@ -15,18 +16,26 @@ class BaseCloudKitTests: @unchecked Sendable { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - var underlyingSyncEngine: MockSyncEngine { - _underlyingSyncEngine as! MockSyncEngine + var privateSyncEngine: MockSyncEngine { + _privateSyncEngine as! MockSyncEngine + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var sharedSyncEngine: MockSyncEngine { + _sharedSyncEngine as! MockSyncEngine } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) init() async throws { let database = try SharingGRDBTests.database() - let underlyingSyncEngine = MockSyncEngine(state: MockSyncEngineState()) + let privateSyncEngine = MockSyncEngine(scope: .private, state: MockSyncEngineState()) + let sharedSyncEngine = MockSyncEngine(scope: .shared, state: MockSyncEngineState()) self.database = database - self._underlyingSyncEngine = underlyingSyncEngine + _privateSyncEngine = privateSyncEngine + _sharedSyncEngine = sharedSyncEngine _syncEngine = try SyncEngine( - defaultSyncEngine: underlyingSyncEngine, + privateSyncEngine: privateSyncEngine, + sharedSyncEngine: sharedSyncEngine, database: database, metadatabaseURL: URL.temporaryDirectory.appending( path: "metadatabase.\(UUID().uuidString).sqlite" @@ -34,14 +43,20 @@ class BaseCloudKitTests: @unchecked Sendable { tables: [Reminder.self, RemindersList.self, User.self] ) try await Task.sleep(for: .seconds(0.1)) - underlyingSyncEngine.assertFetchChangesScopes([.all]) + privateSyncEngine.assertFetchChangesScopes([.all]) + sharedSyncEngine.assertFetchChangesScopes([.all]) } deinit { if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - underlyingSyncEngine.assertFetchChangesScopes([]) - underlyingSyncEngine.state.assertPendingDatabaseChanges([]) - underlyingSyncEngine.state.assertPendingRecordZoneChanges([]) + sharedSyncEngine.assertFetchChangesScopes([]) + sharedSyncEngine.state.assertPendingDatabaseChanges([]) + sharedSyncEngine.state.assertPendingRecordZoneChanges([]) + sharedSyncEngine.assertAcceptedShareMetadata([]) + privateSyncEngine.assertFetchChangesScopes([]) + privateSyncEngine.state.assertPendingDatabaseChanges([]) + privateSyncEngine.state.assertPendingRecordZoneChanges([]) + privateSyncEngine.assertAcceptedShareMetadata([]) } else { Issue.record("Tests must be run on iOS 17+,m macOS 14+, tvOS 17+ and watchOS 10+.") } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index aba63170..066a832d 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -13,19 +13,28 @@ extension CKRecord.ID { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -final class MockSyncEngine: CKSyncEngineProtocol { +final class MockSyncEngine: SyncEngineProtocol { private let _state: LockIsolated private let _fetchChangesScopes = LockIsolated>([]) - init(state: MockSyncEngineState) { + private let _acceptedShareMetadata = LockIsolated>([]) + + let scope: CKDatabase.Scope + init(scope: CKDatabase.Scope, state: MockSyncEngineState) { + self.scope = scope self._state = LockIsolated(state) } var state: MockSyncEngineState { _state.withValue(\.self) } + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { _ = _fetchChangesScopes.withValue { $0.insert(options.scope) } } + func acceptShare(metadata: ShareMetadata) { + _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } + } + func assertFetchChangesScopes( _ scopes: Set, fileID: StaticString = #fileID, @@ -45,6 +54,26 @@ final class MockSyncEngine: CKSyncEngineProtocol { $0.removeAll() } } + + func assertAcceptedShareMetadata( + _ sharedMetadata: Set, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _acceptedShareMetadata.withValue { + expectNoDifference( + sharedMetadata, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From b579496940dadfbe3e0ae79c7755144d60d2ce4e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 20:52:44 -0700 Subject: [PATCH 100/581] wip --- Examples/Reminders/RemindersDetail.swift | 61 +++++++++++------------- Examples/Reminders/SearchReminders.swift | 4 +- 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 55cce2f8..6bf0b907 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -100,35 +100,35 @@ struct RemindersDetailView: View { .tint(detailType.color) } } -// ToolbarItem(placement: .primaryAction) { -// Menu { -// Group { -// Menu { -// ForEach(Ordering.allCases, id: \.self) { ordering in -// Button { -// self.ordering = ordering -// } label: { -// Text(ordering.rawValue) -// ordering.icon -// } -// } -// } label: { -// Text("Sort By") -// Text(ordering.rawValue) -// Image(systemName: "arrow.up.arrow.down") -// } -// Button { -// showCompleted.toggle() -// } label: { -// Text(showCompleted ? "Hide Completed" : "Show Completed") -// Image(systemName: showCompleted ? "eye.slash.fill" : "eye") -// } -// } -// .tint(detailType.color) -// } label: { -// Image(systemName: "ellipsis.circle") -// } -// } + ToolbarItem(placement: .primaryAction) { + Menu { + Group { + Menu { + ForEach(Ordering.allCases, id: \.self) { ordering in + Button { + self.ordering = ordering + } label: { + Text(ordering.rawValue) + ordering.icon + } + } + } label: { + Text("Sort By") + Text(ordering.rawValue) + Image(systemName: "arrow.up.arrow.down") + } + Button { + showCompleted.toggle() + } label: { + Text(showCompleted ? "Hide Completed" : "Show Completed") + Image(systemName: showCompleted ? "eye.slash.fill" : "eye") + } + } + .tint(detailType.color) + } label: { + Image(systemName: "ellipsis.circle") + } + } if let remindersList = detailType.list { ToolbarItem { Button { @@ -139,9 +139,6 @@ struct RemindersDetailView: View { .sheet(item: $presentedShare, id: \.self) { share in CloudSharingView2(share: share) } -// .sheet(isPresented: $isSharePresented) { -// //CloudSharingView(remindersList) -// } } } } diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 24ce8edc..f533ad5a 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -3,7 +3,7 @@ import SharingGRDB import SwiftUI struct SearchRemindersView: View { - @FetchOne var completedCount: Int = 0 + @State @FetchOne var completedCount: Int = 0 @State @FetchAll var reminders: [ReminderState] let searchText: String @@ -62,7 +62,7 @@ struct SearchRemindersView: View { if searchText.isEmpty { showCompletedInSearchResults = false } - try await $completedCount.load( + try await $completedCount.wrappedValue.load( Reminder.searching(searchText) .where(\.isCompleted) .count(), From af3c42d0ada538dcc4638c05f9e912fcf68e3410 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 1 Jun 2025 18:36:52 -0700 Subject: [PATCH 101/581] wip --- Package.resolved | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/Package.resolved b/Package.resolved index da90bdc7..f274d912 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0a9d215bd95753f1b1f21ad3580ad7325b2a80e8876732095c77b2454586e3b9", + "originHash" : "794a047bafc2275bf9dfcec9ec08b24621f054b9b331fc8dbfec924f4d5eb72c", "pins" : [ { "identity" : "combine-schedulers", @@ -19,15 +19,6 @@ "version" : "7.4.1" } }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", - "version" : "1.7.0" - } - }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -100,15 +91,6 @@ "version" : "1.1.1" } }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", - "version" : "2.3.0" - } - }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", From 1d308df27658ae83bc12a485b6754bfdbcd0d310 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 1 Jun 2025 19:57:38 -0700 Subject: [PATCH 102/581] clean up --- Examples/Reminders/RemindersDetail.swift | 8 +- .../CloudKit/CloudKitSharing.swift | 91 +++++++++++++++++++ .../CloudKit/CloudSharingController.swift | 72 --------------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 53 +---------- 4 files changed, 96 insertions(+), 128 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift delete mode 100644 Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 6bf0b907..d5429fa5 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -13,7 +13,7 @@ struct RemindersDetailView: View { @State var isNewReminderSheetPresented = false @State var isNavigationTitleVisible = false @State var navigationTitleHeight: CGFloat = 36 - @State var presentedShare: CKShare? + @State var sharedRecord: SharedRecord? @Dependency(\.defaultDatabase) private var database @@ -136,8 +136,8 @@ struct RemindersDetailView: View { } label: { Image(systemName: "square.and.arrow.up") } - .sheet(item: $presentedShare, id: \.self) { share in - CloudSharingView2(share: share) + .sheet(item: $sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) } } } @@ -148,7 +148,7 @@ struct RemindersDetailView: View { private func shareButtonTapped(remindersList: RemindersList) { Task { await withErrorReporting { - presentedShare = try await syncEngine.createShare(record: remindersList) { + sharedRecord = try await syncEngine.createShare(record: remindersList) { $0[CKShare.SystemFieldKey.title] = remindersList.title as CKRecordValue } } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift new file mode 100644 index 00000000..d0865b08 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -0,0 +1,91 @@ +import CloudKit +import SwiftUI +import UIKit + +public struct SharedRecord: Hashable, Identifiable, Sendable { + public let container: CKContainer + public let share: CKShare + + public var id: CKRecord.ID { share.recordID } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine { + public struct CantShareRecordWithParent: Error {} + public struct NoCKRecordFound: Error {} + + public func createShare( + record: T, + configure: @Sendable (CKShare) -> Void + ) async throws -> SharedRecord + where T.TableColumns.PrimaryKey == UUID { + guard foreignKeysByTableName[T.tableName]?.count(where: \.notnull) ?? 0 == 0 + else { + throw CantShareRecordWithParent() + } + + let recordName = record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() + let lastKnownServerRecord = + try await database.write { db in + try Metadata + .find(recordID: CKRecord.ID(recordName: recordName)) + .select(\.lastKnownServerRecord) + .fetchOne(db) + } ?? nil + + guard let lastKnownServerRecord + else { + throw NoCKRecordFound() + } + + let sharedRecord: CKShare + if let existingShareRecordID = lastKnownServerRecord.share?.recordID, + let existingShare = try await container.privateCloudDatabase.record( + for: existingShareRecordID + ) as? CKShare + { + sharedRecord = existingShare + } else { + sharedRecord = CKShare(rootRecord: lastKnownServerRecord) + } + configure(sharedRecord) + _ = try await container.privateCloudDatabase.modifyRecords( + saving: [sharedRecord, lastKnownServerRecord], + deleting: [] + ) + + sharedRecord.recordType + + return SharedRecord(container: container, share: sharedRecord) + } + + public func acceptShare(metadata: CKShare.Metadata) async throws { + try await sharedSyncEngine.acceptShare(metadata: ShareMetadata(rawValue: metadata)) + } +} + +// TODO: Handle: SharingGRDB CloudKit Failure: No table to delete from: "cloudkit.share" +// TODO: what kind of APIs do we need to expose for people to query for shared info? participants + +#if canImport(UIKit) +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +public struct CloudSharingView: UIViewControllerRepresentable { + let sharedRecord: SharedRecord + public init(sharedRecord: SharedRecord) { + self.sharedRecord = sharedRecord + } + + public func makeUIViewController(context: Context) -> UICloudSharingController { + UICloudSharingController( + share: sharedRecord.share, + container: sharedRecord.container + ) + } + + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } +} +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift b/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift deleted file mode 100644 index 2b7ffe5c..00000000 --- a/Sources/SharingGRDBCore/CloudKit/CloudSharingController.swift +++ /dev/null @@ -1,72 +0,0 @@ -import CloudKit -import SwiftUI -import UIKit - -#if canImport(UIKit) -//import UIKit -//extension UICloudSharingController { -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// public convenience init(_ record: T) -// where T.TableColumns.PrimaryKey == UUID { -// // TODO: Remove UUID constraint by reaching into metadata table -// // TODO: verify that table has no foreign keys -// @Dependency(\.defaultSyncEngine) var syncEngine -// let record = try! syncEngine.database.write { db in -// return -// try Metadata -// .find( -// recordID: CKRecord.ID.init( -// recordName: record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() -// ) -// ) -// .select(\.lastKnownServerRecord) -// .fetchOne(db) -// } -// self.init( -// share: CKShare(rootRecord: record!!), -// container: syncEngine.container -// ) -// } -//} -// -//import SwiftUI -// -//@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -//public struct CloudSharingView: UIViewControllerRepresentable -//where T.TableColumns.PrimaryKey == UUID { -// let record: T -// public init(_ record: T) { -// self.record = record -// } -// -// public func makeUIViewController(context: Context) -> UICloudSharingController { -// UICloudSharingController(record) -// } -// -// public func updateUIViewController( -// _ uiViewController: UICloudSharingController, -// context: Context -// ) { -// } -//} -// -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -public struct CloudSharingView2: UIViewControllerRepresentable { - let share: CKShare - public init(share: CKShare) { - self.share = share - } - - public func makeUIViewController(context: Context) -> UICloudSharingController { - // TODO: Should we take the container from the sync engine or should we require it to be passed in? - @Dependency(\.defaultSyncEngine) var syncEngine - return UICloudSharingController(share: share, container: syncEngine.container) - } - - public func updateUIViewController( - _ uiViewController: UICloudSharingController, - context: Context - ) { - } -} -#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 2c5a6ad1..70fb91be 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -14,7 +14,7 @@ public final actor SyncEngine { private let metadatabaseURL: URL let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] - fileprivate let foreignKeysByTableName: [String: [ForeignKey]] + let foreignKeysByTableName: [String: [ForeignKey]] var privateSyncEngine: (any SyncEngineProtocol)! var sharedSyncEngine: (any SyncEngineProtocol)! let defaultSyncEngines: @@ -894,54 +894,3 @@ extension URL { ) } } - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine { - public struct CantShareRecordWithParent: Error {} - public struct NoCKRecordFound: Error {} - - public func createShare( - record: T, - configure: @Sendable (CKShare) -> Void - ) async throws -> CKShare - where T.TableColumns.PrimaryKey == UUID { - guard foreignKeysByTableName[T.tableName]?.count(where: \.notnull) ?? 0 == 0 - else { - throw CantShareRecordWithParent() - } - - let recordName = record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() - let lastKnownServerRecord = - try await database.write { db in - try Metadata - .find(recordID: CKRecord.ID(recordName: recordName)) - .select(\.lastKnownServerRecord) - .fetchOne(db) - } ?? nil - - guard let lastKnownServerRecord - else { - throw NoCKRecordFound() - } - - let shareID = CKRecord.ID( - recordName: UUID().uuidString, - zoneID: lastKnownServerRecord.recordID.zoneID - ) - let share = CKShare(rootRecord: lastKnownServerRecord, shareID: shareID) - configure(share) - _ = try await container.privateCloudDatabase.modifyRecords( - saving: [share, lastKnownServerRecord], - deleting: [] - ) - - return share - } - - public func acceptShare(metadata: CKShare.Metadata) async throws { - try await sharedSyncEngine.acceptShare(metadata: ShareMetadata(rawValue: metadata)) - } -} - -// TODO: Handle: SharingGRDB CloudKit Failure: No table to delete from: "cloudkit.share" -// TODO: what kind of APIs do we need to expose for people to query for shared info? participants From a1100219e2490b7234db0a601868d3049f044149 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Jun 2025 15:00:59 -0700 Subject: [PATCH 103/581] wip --- Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index d0865b08..ce7ec410 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -1,6 +1,9 @@ import CloudKit import SwiftUI + +#if canImport(UIKit) import UIKit +#endif public struct SharedRecord: Hashable, Identifiable, Sendable { public let container: CKContainer @@ -54,8 +57,6 @@ extension SyncEngine { deleting: [] ) - sharedRecord.recordType - return SharedRecord(container: container, share: sharedRecord) } From d0fa16645d5c648eb060f45b6ff81c036eabf938 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Jun 2025 15:29:19 -0700 Subject: [PATCH 104/581] wip --- Package.resolved | 20 ++++- .../CloudKit/CloudKitSharing.swift | 74 ++++++++++++------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 16 ++++ 3 files changed, 83 insertions(+), 27 deletions(-) diff --git a/Package.resolved b/Package.resolved index f274d912..da90bdc7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "794a047bafc2275bf9dfcec9ec08b24621f054b9b331fc8dbfec924f4d5eb72c", + "originHash" : "0a9d215bd95753f1b1f21ad3580ad7325b2a80e8876732095c77b2454586e3b9", "pins" : [ { "identity" : "combine-schedulers", @@ -19,6 +19,15 @@ "version" : "7.4.1" } }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", + "version" : "1.7.0" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -91,6 +100,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", + "version" : "2.3.0" + } + }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index ce7ec410..c297420e 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -2,7 +2,7 @@ import CloudKit import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif public struct SharedRecord: Hashable, Identifiable, Sendable { @@ -17,6 +17,24 @@ extension SyncEngine { public struct CantShareRecordWithParent: Error {} public struct NoCKRecordFound: Error {} + // syncEngine.record(remindersList) + public func records(for _: T.Type) async throws -> [CKRecord] { + [] + } + + public func record(for record: T) async throws -> CKRecord? { + nil + } + + public func shares(for _: T.Type) async throws -> [CKShare] { + [] + } + + public func share(_ record: T) async throws -> CKShare? { + nil + } + + // TODO: upsertShare / share public func createShare( record: T, configure: @Sendable (CKShare) -> Void @@ -29,12 +47,12 @@ extension SyncEngine { let recordName = record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() let lastKnownServerRecord = - try await database.write { db in - try Metadata - .find(recordID: CKRecord.ID(recordName: recordName)) - .select(\.lastKnownServerRecord) - .fetchOne(db) - } ?? nil + try await database.write { db in + try Metadata + .find(recordID: CKRecord.ID(recordName: recordName)) + .select(\.lastKnownServerRecord) + .fetchOne(db) + } ?? nil guard let lastKnownServerRecord else { @@ -43,14 +61,18 @@ extension SyncEngine { let sharedRecord: CKShare if let existingShareRecordID = lastKnownServerRecord.share?.recordID, - let existingShare = try await container.privateCloudDatabase.record( + let existingShare = try await container.privateCloudDatabase.record( for: existingShareRecordID - ) as? CKShare + ) as? CKShare { sharedRecord = existingShare } else { sharedRecord = CKShare(rootRecord: lastKnownServerRecord) } + + // TODO: upsert "metadata" and store the sharedID and/or the full serialized CKShare? + // TODO: where we currently have purple warnings about cloudkit.share we should upsert that info into Metadata + configure(sharedRecord) _ = try await container.privateCloudDatabase.modifyRecords( saving: [sharedRecord, lastKnownServerRecord], @@ -69,24 +91,24 @@ extension SyncEngine { // TODO: what kind of APIs do we need to expose for people to query for shared info? participants #if canImport(UIKit) -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -public struct CloudSharingView: UIViewControllerRepresentable { - let sharedRecord: SharedRecord - public init(sharedRecord: SharedRecord) { - self.sharedRecord = sharedRecord - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct CloudSharingView: UIViewControllerRepresentable { + let sharedRecord: SharedRecord + public init(sharedRecord: SharedRecord) { + self.sharedRecord = sharedRecord + } - public func makeUIViewController(context: Context) -> UICloudSharingController { - UICloudSharingController( - share: sharedRecord.share, - container: sharedRecord.container - ) - } + public func makeUIViewController(context: Context) -> UICloudSharingController { + UICloudSharingController( + share: sharedRecord.share, + container: sharedRecord.container + ) + } - public func updateUIViewController( - _ uiViewController: UICloudSharingController, - context: Context - ) { + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } } -} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 70fb91be..480a8401 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -703,6 +703,22 @@ extension SyncEngine: CKSyncEngineDelegate { } private func mergeFromServerRecord(_ record: CKRecord) { + +// Task { +// guard +// let share = record as? CKShare, +// let url = share.url +// else { return } +// let operation = CKFetchShareMetadataOperation(shareURLs: [url]) +// operation.shouldFetchRootRecord = true +// operation.perShareMetadataResultBlock = { url, result in +// // TODO: Upsert metadata +// print(try? result.get().rootRecord) +// print("!!!!") +// } +// container.add(operation) +// } + $isUpdatingWithServerRecord.withValue(true) { $currentZoneID.withValue(record.recordID.zoneID) { withErrorReporting(.sqliteDataCloudKitFailure) { From 284f0d387f580e233e9e2906ae8ff434b75703a1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Jun 2025 21:35:24 -0700 Subject: [PATCH 105/581] lots of work on sharing --- Examples/Reminders/RemindersListRow.swift | 22 +++++- .../CloudKit/CloudKit+StructuredQueries.swift | 31 ++++++++ .../CloudKit/CloudKitSharing.swift | 33 ++++++-- .../SharingGRDBCore/CloudKit/Metadata.swift | 11 ++- .../CloudKit/MetadataTable.swift | 33 +++++++- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 77 +++++++++++++------ .../CloudKitTests/MetadataTests.swift | 53 +++++++++++++ .../CloudKitTests/TriggerTests.swift | 21 +++-- 8 files changed, 228 insertions(+), 53 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 4a6bdc87..c7940a61 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -1,3 +1,4 @@ +import CloudKit import SharingGRDB import SwiftUI @@ -6,8 +7,10 @@ struct RemindersListRow: View { let remindersList: RemindersList @State var editList: RemindersList? + @State var participantNames: String? @Dependency(\.defaultDatabase) private var database + @Dependency(\.defaultSyncEngine) private var syncEngine var body: some View { HStack { @@ -17,7 +20,13 @@ struct RemindersListRow: View { .background( Color.white.clipShape(Circle()).padding(4) ) - Text(remindersList.title) + VStack(alignment: .leading, spacing: 4) { + Text(remindersList.title) + if let participantNames { + Text("Shared with \(participantNames)") + .foregroundStyle(Color.secondary) + } + } Spacer() Text("\(remindersCount)") .foregroundStyle(.gray) @@ -47,6 +56,17 @@ struct RemindersListRow: View { } .presentationDetents([.medium]) } + .task { + await withErrorReporting { + guard let share = try await syncEngine.share(for: remindersList) + else { return } + participantNames = share.participants + .dropFirst() + //.filter { $0.userIdentity. != CKCurrentUserDefaultName } + .compactMap { $0.userIdentity.nameComponents?.formatted() } + .joined(separator: ", ") + } + } } } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 14297163..691b923b 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -32,10 +32,41 @@ extension CKRecord { } } +extension CKShare { + package struct ShareDataRepresentation: QueryBindable, QueryRepresentable { + package let queryOutput: CKShare + + package var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encodeSystemFields(with: archiver) + return archiver.encodedData.queryBinding + } + + package init(queryOutput: CKShare) { + self.queryOutput = queryOutput + } + + package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + guard let data = try Data?(decoder: &decoder) else { + throw QueryDecodingError.missingRequiredColumn + } + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + self.init(queryOutput: CKShare(coder: coder)) + } + + private struct DecodingError: Error {} + } +} + extension CKRecord? { package typealias DataRepresentation = CKRecord.DataRepresentation? } +extension CKShare? { + package typealias ShareDataRepresentation = CKShare.ShareDataRepresentation? +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { func update(with row: T, userModificationDate: Date?) { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index c297420e..b2048102 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -17,21 +17,38 @@ extension SyncEngine { public struct CantShareRecordWithParent: Error {} public struct NoCKRecordFound: Error {} - // syncEngine.record(remindersList) - public func records(for _: T.Type) async throws -> [CKRecord] { - [] - } +// public func records(for _: T.Type) async throws -> [CKRecord] { +// [] +// } public func record(for record: T) async throws -> CKRecord? { nil } - public func shares(for _: T.Type) async throws -> [CKShare] { - [] + public func shares(for _: T.Type) throws -> [CKShare] { + try metadatabase.read { db in + try Metadata + .where { $0.recordType.eq(T.tableName) && $0.share.isNot(nil) } + .select { $0.share } + .fetchAll(db) + .compactMap(\.self) + } } - public func share(_ record: T) async throws -> CKShare? { - nil + public func share( + for record: T + ) throws -> CKShare? + where T.TableColumns.PrimaryKey == UUID + { + try metadatabase.read { db in + try Metadata + .where { + $0.recordName.eq(record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased()) + && $0.recordType.eq(T.tableName) + } + .select { $0.share } + .fetchOne(db) ?? nil + } } // TODO: upsertShare / share diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 3290d88b..4eeb332b 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -73,7 +73,7 @@ extension Metadata { parentForeignKey: ForeignKey?, db: Database ) throws { - let foreignKeyName = (parentForeignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" + let foreignKey = (parentForeignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" try SQLQueryExpression( """ @@ -101,10 +101,10 @@ extension Metadata { \(raw: .sqliteDataCloudKitSchemaName)_getOwnerName(), \(quote: SyncEngine.defaultZone.zoneID.ownerName, delimiter: .text) ), - \(raw: foreignKeyName) AS "foreignKeyName", + \(raw: foreignKey) AS "foreignKey", datetime('subsec') FROM (SELECT 1) - LEFT JOIN \(Metadata.self) ON \(Metadata.recordName) = "foreignKeyName" + LEFT JOIN \(Metadata.self) ON \(Metadata.recordName) = "foreignKey" ON CONFLICT("recordName") DO NOTHING; END """ @@ -118,9 +118,8 @@ extension Metadata { SET "recordName" = "new".\(quote: T.columns.primaryKey.name), "userModificationDate" = datetime('subsec'), - "parentRecordName" = \(raw: foreignKeyName) - WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name) - ; + "parentRecordName" = \(raw: foreignKey) + WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name); END """ ) diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift index c885460f..1346bb1c 100644 --- a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift @@ -2,7 +2,7 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") -package struct Metadata { +package struct Metadata: Hashable { package var recordType: String package var recordName: String package var zoneName: String @@ -10,10 +10,29 @@ package struct Metadata { package var parentRecordName: String? // @Column(as: CKRecord?.DataRepresentation.self) package var lastKnownServerRecord: CKRecord? + // @Column(as: CKShare?.ShareDataRepresentation.self) + package var share: CKShare? package var userModificationDate: Date? + + package init( + recordType: String, + recordName: String, + zoneName: String, + ownerName: String, + parentRecordName: String? = nil, + lastKnownServerRecord: CKRecord? = nil, + userModificationDate: Date? = nil + ) { + self.recordType = recordType + self.recordName = recordName + self.zoneName = zoneName + self.ownerName = ownerName + self.parentRecordName = parentRecordName + self.lastKnownServerRecord = lastKnownServerRecord + self.userModificationDate = userModificationDate + } } -// NB: This is generated by inlining the above macro applications. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table { public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Metadata @@ -23,13 +42,14 @@ package struct Metadata { public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.userModificationDate] + [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] } } public static let columns = TableColumns() - public static let tableName = "\(String.sqliteDataCloudKitSchemaName)_metadata" + public static let tableName = "sqlitedata_icloud_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) @@ -37,6 +57,7 @@ package struct Metadata { let ownerName = try decoder.decode(String.self) self.parentRecordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) + let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) guard let recordType else { throw QueryDecodingError.missingRequiredColumn @@ -53,11 +74,15 @@ package struct Metadata { guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } + guard let share else { + throw QueryDecodingError.missingRequiredColumn + } self.recordType = recordType self.recordName = recordName self.zoneName = zoneName self.ownerName = ownerName self.lastKnownServerRecord = lastKnownServerRecord + self.share = share } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 480a8401..9b66b0d7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -135,6 +135,8 @@ public final actor SyncEngine { #endif migrator.registerMigration("Create Metadata Tables") { db in // TODO: Should "recordName" be "collate no case"? + // TODO: should primary key be (recordType, recordName) so that we can use autoincrementing + // UUIDs in tests? try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( @@ -144,6 +146,7 @@ public final actor SyncEngine { "ownerName" TEXT NOT NULL, "parentRecordName" TEXT, "lastKnownServerRecord" BLOB, + "share" BLOB, "userModificationDate" TEXT ) STRICT """ @@ -390,7 +393,7 @@ extension SyncEngine: CKSyncEngineDelegate { case .sentDatabaseChanges: break case .fetchedRecordZoneChanges(let event): - handleFetchedRecordZoneChanges( + await handleFetchedRecordZoneChanges( modifications: event.modifications.map(\.record), deletions: event.deletions.map { ($0.recordID, $0.recordType) } ) @@ -611,11 +614,21 @@ extension SyncEngine: CKSyncEngineDelegate { package func handleFetchedRecordZoneChanges( modifications: [CKRecord], deletions: [(CKRecord.ID, CKRecord.RecordType)] - ) { - $isUpdatingWithServerRecord.withValue(true) { - for modifiedRecord in modifications { - mergeFromServerRecord(modifiedRecord) - refreshLastKnownServerRecord(modifiedRecord) + ) async { + await $isUpdatingWithServerRecord.withValue(true) { + await withTaskGroup { group in + for record in modifications { + group.addTask { + if let share = record as? CKShare { + await withErrorReporting { + try await self.cacheShare(share) + } + } else { + await self.upsertFromServerRecord(record) + await self.refreshLastKnownServerRecord(record) + } + } + } } for (recordID, recordType) in deletions { @@ -630,6 +643,8 @@ extension SyncEngine: CKSyncEngineDelegate { } } open(table) + } else if recordType == CKRecord.SystemType.share { + // TODO: When we get a CKShare here do we delete it from the metadata and then delete it from the user database? } else { reportIssue( .sqliteDataCloudKitFailure.appending( @@ -676,7 +691,7 @@ extension SyncEngine: CKSyncEngineDelegate { switch failedRecordSave.error.code { case .serverRecordChanged: guard let serverRecord = failedRecordSave.error.serverRecord else { continue } - mergeFromServerRecord(serverRecord) + upsertFromServerRecord(serverRecord) refreshLastKnownServerRecord(serverRecord) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) @@ -702,23 +717,24 @@ extension SyncEngine: CKSyncEngineDelegate { // TODO: handle event.failedRecordDeletes ? look at apple sample code } - private func mergeFromServerRecord(_ record: CKRecord) { + private func cacheShare(_ share: CKShare) async throws { + guard let url = share.url + else { return } + + let metadata = try await self.container.shareMetadata(for: url, shouldFetchRootRecord: true) -// Task { -// guard -// let share = record as? CKShare, -// let url = share.url -// else { return } -// let operation = CKFetchShareMetadataOperation(shareURLs: [url]) -// operation.shouldFetchRootRecord = true -// operation.perShareMetadataResultBlock = { url, result in -// // TODO: Upsert metadata -// print(try? result.get().rootRecord) -// print("!!!!") -// } -// container.add(operation) -// } + guard let rootRecord = metadata.rootRecord + else { return } + try await database.write { db in + try Metadata + .find(recordID: rootRecord.recordID) + .update { $0.share = share } + .execute(db) + } + } + + private func upsertFromServerRecord(_ record: CKRecord) { $isUpdatingWithServerRecord.withValue(true) { $currentZoneID.withValue(record.recordID.zoneID) { withErrorReporting(.sqliteDataCloudKitFailure) { @@ -910,3 +926,20 @@ extension URL { ) } } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKContainer { + func shareMetadata( + for url: URL, + shouldFetchRootRecord: Bool = false + ) async throws -> CKShare.Metadata { + try await withUnsafeThrowingContinuation { continuation in + let operation = CKFetchShareMetadataOperation(shareURLs: [url]) + operation.shouldFetchRootRecord = true + operation.perShareMetadataResultBlock = { url, result in + continuation.resume(with: result) + } + add(operation) + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift new file mode 100644 index 00000000..1643b58a --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -0,0 +1,53 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func parentRecordName() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: UUID(2), title: "Work") + Reminder(id: UUID(3), title: "Groceries", remindersListID: UUID(1)) + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(1))), + .saveRecord(CKRecord.ID(UUID(2))), + .saveRecord(CKRecord.ID(UUID(3))), + ]) + + try database.write { db in + let reminderMetadata = try #require( + try Metadata + .find(recordID: CKRecord.ID(UUID(3))) + .fetchOne(db) + ) + #expect(reminderMetadata.parentRecordName == UUID(1).uuidString) + } + + try database.write { db in + try Reminder.find(UUID(3)) + .update { $0.remindersListID = UUID(2) } + .execute(db) + } + try database.write { db in + let reminderMetadata = try #require( + try Metadata + .find(recordID: CKRecord.ID(UUID(3))) + .fetchOne(db) + ) + #expect(reminderMetadata.parentRecordName == UUID(2).uuidString) + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(CKRecord.ID(UUID(3))), + ]) + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 5023519d..7f2ee8b4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -83,10 +83,10 @@ extension BaseCloudKitTests { sqlitedata_icloud_getOwnerName(), '__defaultOwner__' ), - "new"."remindersListID" AS "foreignKeyName", + "new"."remindersListID" AS "foreignKey", datetime('subsec') FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKeyName" + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" ON CONFLICT("recordName") DO NOTHING; END """, @@ -98,8 +98,7 @@ extension BaseCloudKitTests { "recordName" = "new"."id", "userModificationDate" = datetime('subsec'), "parentRecordName" = "new"."remindersListID" - WHERE "recordName" = "old"."id" - ; + WHERE "recordName" = "old"."id"; END """, [5]: """ @@ -187,10 +186,10 @@ extension BaseCloudKitTests { sqlitedata_icloud_getOwnerName(), '__defaultOwner__' ), - NULL AS "foreignKeyName", + NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKeyName" + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" ON CONFLICT("recordName") DO NOTHING; END """, @@ -202,8 +201,7 @@ extension BaseCloudKitTests { "recordName" = "new"."id", "userModificationDate" = datetime('subsec'), "parentRecordName" = NULL - WHERE "recordName" = "old"."id" - ; + WHERE "recordName" = "old"."id"; END """, [14]: """ @@ -238,10 +236,10 @@ extension BaseCloudKitTests { sqlitedata_icloud_getOwnerName(), '__defaultOwner__' ), - NULL AS "foreignKeyName", + NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKeyName" + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" ON CONFLICT("recordName") DO NOTHING; END """, @@ -253,8 +251,7 @@ extension BaseCloudKitTests { "recordName" = "new"."id", "userModificationDate" = datetime('subsec'), "parentRecordName" = NULL - WHERE "recordName" = "old"."id" - ; + WHERE "recordName" = "old"."id"; END """, [17]: """ From cdc010dcaa3b7970faa0314d0d62967c83d3be50 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 3 Jun 2025 09:32:55 -0700 Subject: [PATCH 106/581] wip --- Examples/Reminders/RemindersListRow.swift | 4 ++- .../CloudKit/CloudKitSharing.swift | 29 ++++++++++++++----- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 12 +++++++- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index c7940a61..a8937dc4 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -24,6 +24,7 @@ struct RemindersListRow: View { Text(remindersList.title) if let participantNames { Text("Shared with \(participantNames)") + .font(.footnote) .foregroundStyle(Color.secondary) } } @@ -60,9 +61,10 @@ struct RemindersListRow: View { await withErrorReporting { guard let share = try await syncEngine.share(for: remindersList) else { return } + let currentUserRecordID = try await CKContainer.default().userRecordID() participantNames = share.participants .dropFirst() - //.filter { $0.userIdentity. != CKCurrentUserDefaultName } + .filter { $0.userIdentity.userRecordID != currentUserRecordID } .compactMap { $0.userIdentity.nameComponents?.formatted() } .joined(separator: ", ") } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index b2048102..2d440752 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -22,14 +22,21 @@ extension SyncEngine { // } public func record(for record: T) async throws -> CKRecord? { - nil + let lastKnownServerRecord = try await metadatabase.read { db in + try Metadata + .where { $0.recordType.eq(T.tableName) } + .select(\.lastKnownServerRecord) + .fetchOne(db) ?? nil + } + + return lastKnownServerRecord } public func shares(for _: T.Type) throws -> [CKShare] { try metadatabase.read { db in try Metadata - .where { $0.recordType.eq(T.tableName) && $0.share.isNot(nil) } - .select { $0.share } + .where { $0.recordType.eq(T.tableName) } + .select(\.share) .fetchAll(db) .compactMap(\.self) } @@ -37,21 +44,27 @@ extension SyncEngine { public func share( for record: T - ) throws -> CKShare? + ) async throws -> CKShare? where T.TableColumns.PrimaryKey == UUID { - try metadatabase.read { db in + let primaryKey = record[keyPath: T.columns.primaryKey.keyPath] + let share = try await metadatabase.read { db in try Metadata .where { - $0.recordName.eq(record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased()) + $0.recordName.eq(primaryKey.uuidString.lowercased()) && $0.recordType.eq(T.tableName) } - .select { $0.share } + .select(\.share) .fetchOne(db) ?? nil } + guard let share + else { return nil } + return (try await container.sharedCloudDatabase.record(for: share.recordID) as? CKShare) + ?? share } - // TODO: upsertShare / share + // TODO: upsertShare / share. + // share(record:) is very similar to share(for:) public func createShare( record: T, configure: @Sendable (CKShare) -> Void diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 9b66b0d7..25e9d721 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -644,7 +644,9 @@ extension SyncEngine: CKSyncEngineDelegate { } open(table) } else if recordType == CKRecord.SystemType.share { - // TODO: When we get a CKShare here do we delete it from the metadata and then delete it from the user database? + await withErrorReporting { + try deleteShare(recordID: recordID, recordType: recordType) + } } else { reportIssue( .sqliteDataCloudKitFailure.appending( @@ -734,6 +736,14 @@ extension SyncEngine: CKSyncEngineDelegate { } } + private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { + try metadatabase.write { db in + try Metadata.find(recordID: recordID) + .update { $0.share = nil } + .execute(db) + } + } + private func upsertFromServerRecord(_ record: CKRecord) { $isUpdatingWithServerRecord.withValue(true) { $currentZoneID.withValue(record.recordID.zoneID) { From 6bd3a28a149de7e95277846e8efba0e8164ad664 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 3 Jun 2025 09:42:55 -0700 Subject: [PATCH 107/581] wip --- Examples/Reminders/RemindersListRow.swift | 4 +--- Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index a8937dc4..fe5b1684 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -61,10 +61,8 @@ struct RemindersListRow: View { await withErrorReporting { guard let share = try await syncEngine.share(for: remindersList) else { return } - let currentUserRecordID = try await CKContainer.default().userRecordID() participantNames = share.participants - .dropFirst() - .filter { $0.userIdentity.userRecordID != currentUserRecordID } + .filter { $0 != share.currentUserParticipant } .compactMap { $0.userIdentity.nameComponents?.formatted() } .joined(separator: ", ") } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 2d440752..dec8f020 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -59,7 +59,8 @@ extension SyncEngine { } guard let share else { return nil } - return (try await container.sharedCloudDatabase.record(for: share.recordID) as? CKShare) + // TODO: figure out if this share belongs to us or someone else so that we can choose between privateCloudDatabase and sharedCloudDatabase + return (try await container.privateCloudDatabase.record(for: share.recordID) as? CKShare) ?? share } From bb4296e3620d6604c766034f564d008ecb00769d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 3 Jun 2025 19:22:16 -0700 Subject: [PATCH 108/581] wip --- Examples/Reminders/Schema.swift | 1 + .../CloudKit/CloudKitSharing.swift | 8 +++++++- .../CloudKit/MetadataTable.swift | 18 ------------------ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 1 + 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 6ea42ce9..d68c8f6e 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -93,6 +93,7 @@ func appDatabase() throws -> any DatabaseWriter { configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in #if DEBUG + //db.attachMetadatabase() db.trace(options: .profile) { if context == .preview { print("\($0.expandedDescription)") diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index dec8f020..3ba04296 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -17,6 +17,7 @@ extension SyncEngine { public struct CantShareRecordWithParent: Error {} public struct NoCKRecordFound: Error {} + // TODO: beef up to take query and bundle into @Selection? // public func records(for _: T.Type) async throws -> [CKRecord] { // [] // } @@ -29,7 +30,10 @@ extension SyncEngine { .fetchOne(db) ?? nil } - return lastKnownServerRecord + guard let lastKnownServerRecord + else { return nil } + // TODO: Add logic to determine privateCloudDatabase vs sharedCloudDatabase + return try await container.privateCloudDatabase.record(for: lastKnownServerRecord.recordID) } public func shares(for _: T.Type) throws -> [CKShare] { @@ -59,7 +63,9 @@ extension SyncEngine { } guard let share else { return nil } + // TODO: If we feel confident that our CKShares are always up to date, let's not even refresh // TODO: figure out if this share belongs to us or someone else so that we can choose between privateCloudDatabase and sharedCloudDatabase + // TODO: figure out how to expose private/shared database to outside world return (try await container.privateCloudDatabase.record(for: share.recordID) as? CKShare) ?? share } diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift index 1346bb1c..b41f9d40 100644 --- a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift @@ -13,24 +13,6 @@ package struct Metadata: Hashable { // @Column(as: CKShare?.ShareDataRepresentation.self) package var share: CKShare? package var userModificationDate: Date? - - package init( - recordType: String, - recordName: String, - zoneName: String, - ownerName: String, - parentRecordName: String? = nil, - lastKnownServerRecord: CKRecord? = nil, - userModificationDate: Date? = nil - ) { - self.recordType = recordType - self.recordName = recordName - self.zoneName = zoneName - self.ownerName = ownerName - self.parentRecordName = parentRecordName - self.lastKnownServerRecord = lastKnownServerRecord - self.userModificationDate = userModificationDate - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 25e9d721..a41d6c56 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -262,6 +262,7 @@ public final actor SyncEngine { ) .execute(db) } + // TODO: Instead of deleting let's just empty the database. try metadatabase.close() try FileManager.default.removeItem(at: metadatabaseURL) } From 7f78f946a1a3890bb3608ad46cbce9dcc3866840 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 3 Jun 2025 19:53:28 -0700 Subject: [PATCH 109/581] remove structured task --- Package.resolved | 20 +-- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 140 ++++++++++-------- .../CloudKitTests/CloudKitTests.swift | 16 +- .../CloudKitTests/TriggerTests.swift | 1 - 4 files changed, 95 insertions(+), 82 deletions(-) diff --git a/Package.resolved b/Package.resolved index da90bdc7..f274d912 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0a9d215bd95753f1b1f21ad3580ad7325b2a80e8876732095c77b2454586e3b9", + "originHash" : "794a047bafc2275bf9dfcec9ec08b24621f054b9b331fc8dbfec924f4d5eb72c", "pins" : [ { "identity" : "combine-schedulers", @@ -19,15 +19,6 @@ "version" : "7.4.1" } }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", - "version" : "1.7.0" - } - }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -100,15 +91,6 @@ "version" : "1.1.1" } }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", - "version" : "2.3.0" - } - }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index a41d6c56..4441da61 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -121,11 +121,7 @@ public final actor SyncEngine { _container as! CKContainer } - package func setUpSyncEngine() throws { - defer { - (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) - } - + package func setUpSyncEngine() async throws { metadatabase = try defaultMetadatabase // TODO: go towards idempotent migrations instead of GRDB migrator by the end of all of this var migrator = DatabaseMigrator() @@ -181,16 +177,52 @@ public final actor SyncEngine { .execute(db) } try migrator.migrate(metadatabase) - let previousRecordTypes = try metadatabase.read { db in + + try await database.write { db in + let hasAttachedMetadatabase: Bool = + try SQLQueryExpression( + """ + SELECT count(*) + FROM pragma_database_list + WHERE "name" = \(bind: String.sqliteDataCloudKitSchemaName) + """, + as: Int.self + ) + .fetchOne(db) == 1 + if !hasAttachedMetadatabase { + try SQLQueryExpression( + "ATTACH DATABASE \(self.metadatabaseURL) AS \(quote: .sqliteDataCloudKitSchemaName)" + ) + .execute(db) + } + db.add(function: .isUpdatingWithServerRecord) + db.add(function: .getZoneName) + db.add(function: .getOwnerName) + db.add(function: .didUpdate(syncEngine: self)) + db.add(function: .willDelete(syncEngine: self)) + + try Metadata.createTriggers(tables: self.tables, db: db) + + for table in self.tables { + func open(_: T.Type) throws { + try T.createTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) + } + try open(table) + } + } + + (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) + + let previousRecordTypes = try await metadatabase.read { db in try RecordType.all.fetchAll(db) } - let currentRecordTypes = try database.read { db in + let currentRecordTypes = try await database.read { db in try SQLQueryExpression( """ SELECT "name", "sql" FROM "sqlite_master" WHERE "type" = 'table' - AND "name" IN (\(tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) + AND "name" IN (\(self.tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) """, as: RecordType.self ) @@ -206,7 +238,7 @@ public final actor SyncEngine { } if !recordTypesToFetch.isEmpty { // TODO: Should we avoid this unstructured task by making 'setUpSyncEngine' async? - Task { +// Task { await withErrorReporting(.sqliteDataCloudKitFailure) { try await metadatabase.write { db in for recordType in recordTypesToFetch { @@ -217,27 +249,7 @@ public final actor SyncEngine { await withErrorReporting(.sqliteDataCloudKitFailure) { try await fetchChanges() } - } - } - try database.write { db in - try SQLQueryExpression( - "ATTACH DATABASE \(metadatabaseURL) AS \(quote: .sqliteDataCloudKitSchemaName)" - ) - .execute(db) - db.add(function: .isUpdatingWithServerRecord) - db.add(function: .getZoneName) - db.add(function: .getOwnerName) - db.add(function: .didUpdate(syncEngine: self)) - db.add(function: .willDelete(syncEngine: self)) - - try Metadata.createTriggers(tables: tables, db: db) - - for table in tables { - func open(_: T.Type) throws { - try createTriggers(table: table, db: db) - } - try open(table) - } +// } } } @@ -256,15 +268,18 @@ public final actor SyncEngine { db.remove(function: .getZoneName) db.remove(function: .isUpdatingWithServerRecord) } - try database.writeWithoutTransaction { db in - try SQLQueryExpression( - "DETACH DATABASE \(quote: .sqliteDataCloudKitSchemaName)" - ) - .execute(db) - } - // TODO: Instead of deleting let's just empty the database. - try metadatabase.close() - try FileManager.default.removeItem(at: metadatabaseURL) + // try database.writeWithoutTransaction { db in + // try SQLQueryExpression( + // "DETACH DATABASE \(quote: .sqliteDataCloudKitSchemaName)" + // ) + // .execute(db) + // } + + // // TODO: Instead of deleting let's just empty the database. + // try metadatabase.close() + // try FileManager.default.removeItem(at: metadatabaseURL) + + try metadatabase.erase() } // TODO: resendAll() ? @@ -274,7 +289,7 @@ public final actor SyncEngine { try await sharedSyncEngine.fetchChanges() } - public func deleteLocalData() throws { + public func deleteLocalData() async throws { try tearDownSyncEngine() withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in @@ -288,7 +303,7 @@ public final actor SyncEngine { } } } - try setUpSyncEngine() + try await setUpSyncEngine() } func didUpdate(recordName: String, zoneName: String, ownerName: String) { @@ -356,26 +371,33 @@ public final actor SyncEngine { } } - private func createTriggers(table: T.Type, db: Database) throws { - let foreignKey = - foreignKeysByTableName[T.tableName]?.count(where: \.notnull) == 1 - ? foreignKeysByTableName[T.tableName]?.first(where: \.notnull) - : nil - - try Metadata.createTriggers(for: T.self, parentForeignKey: foreignKey, db: db) - + private func dropTriggers(table: T.Type, db: Database) throws { let foreignKeys = foreignKeysByTableName[T.tableName] ?? [] for foreignKey in foreignKeys { - try foreignKey.createTriggers(for: T.self, db: db) + try foreignKey.dropTriggers(for: T.self, db: db) } + try Metadata.dropTriggers(for: T.self, db: db) } +} - private func dropTriggers(table: T.Type, db: Database) throws { - let foreignKeys = foreignKeysByTableName[T.tableName] ?? [] + +extension PrimaryKeyedTable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func createTriggers( + foreignKeysByTableName: [String: [ForeignKey]], + db: Database + ) throws { + let foreignKey = + foreignKeysByTableName[Self.tableName]?.count(where: \.notnull) == 1 + ? foreignKeysByTableName[Self.tableName]?.first(where: \.notnull) + : nil + + try Metadata.createTriggers(for: Self.self, parentForeignKey: foreignKey, db: db) + + let foreignKeys = foreignKeysByTableName[Self.tableName] ?? [] for foreignKey in foreignKeys { - try foreignKey.dropTriggers(for: T.self, db: db) + try foreignKey.createTriggers(for: Self.self, db: db) } - try Metadata.dropTriggers(for: T.self, db: db) } } @@ -386,7 +408,7 @@ extension SyncEngine: CKSyncEngineDelegate { switch event { case .accountChange(let event): - handleAccountChange(event) + await handleAccountChange(event) case .stateUpdate(let event): handleStateUpdate(event, syncEngine: syncEngine) case .fetchedDatabaseChanges(let event): @@ -537,7 +559,7 @@ extension SyncEngine: CKSyncEngineDelegate { return batch } - private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { + private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) async { switch event.changeType { case .signIn: privateSyncEngine.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) @@ -564,8 +586,8 @@ extension SyncEngine: CKSyncEngineDelegate { } } case .signOut, .switchAccounts: - withErrorReporting(.sqliteDataCloudKitFailure) { - try deleteLocalData() + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await deleteLocalData() } @unknown default: break @@ -645,7 +667,7 @@ extension SyncEngine: CKSyncEngineDelegate { } open(table) } else if recordType == CKRecord.SystemType.share { - await withErrorReporting { + withErrorReporting { try deleteShare(recordID: recordID, recordType: recordType) } } else { diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index c6b2233d..03ff68c3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -58,12 +58,23 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDown() async throws { + _ = try await database.write { db in + try Metadata.count().fetchOne(db) ?? 0 + } + try await syncEngine.tearDownSyncEngine() + await #expect(throws: DatabaseError.self) { + try await database.write { db in + try Metadata.count().fetchOne(db) ?? 0 + } + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDownAndReSetUp() async throws { try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - // TODO: it would be nice if `setUpSyncEngine` was async - try await Task.sleep(for: .seconds(0.1)) privateSyncEngine.assertFetchChangesScopes([.all]) sharedSyncEngine.assertFetchChangesScopes([.all]) @@ -85,7 +96,6 @@ extension BaseCloudKitTests { modifications: [record], deletions: [] ) - try await Task.sleep(for: .seconds(1)) expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Personal") diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 7f2ee8b4..16e6f962 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -294,7 +294,6 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - try await Task.sleep(for: .seconds(0.1)) privateSyncEngine.assertFetchChangesScopes([.all]) sharedSyncEngine.assertFetchChangesScopes([.all]) let triggersAfterReSetUp = try await database.write { db in From 0a0a5de8292790b10d654794d459bd0be0dced23 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 3 Jun 2025 20:23:01 -0700 Subject: [PATCH 110/581] remove another unstructured task. --- .../CloudKit/CloudKit+Helpers.swift | 18 ++ .../CloudKit/CloudKitSharing.swift | 4 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 185 +++++++++--------- .../CloudKit/SyncEngineProtocol.swift | 1 + .../CloudKitTests/CloudKitTests.swift | 2 +- .../Internal/CloudKitTestHelpers.swift | 3 + 6 files changed, 122 insertions(+), 91 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift new file mode 100644 index 00000000..b877da11 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift @@ -0,0 +1,18 @@ +import CloudKit + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension CKContainer { + func shareMetadata( + for url: URL, + shouldFetchRootRecord: Bool = false + ) async throws -> CKShare.Metadata { + try await withUnsafeThrowingContinuation { continuation in + let operation = CKFetchShareMetadataOperation(shareURLs: [url]) + operation.shouldFetchRootRecord = true + operation.perShareMetadataResultBlock = { url, result in + continuation.resume(with: result) + } + add(operation) + } + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 3ba04296..b279de04 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -120,7 +120,9 @@ extension SyncEngine { } public func acceptShare(metadata: CKShare.Metadata) async throws { - try await sharedSyncEngine.acceptShare(metadata: ShareMetadata(rawValue: metadata)) + try await syncEngines + .withValue(\.shared)? + .acceptShare(metadata: ShareMetadata(rawValue: metadata)) } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4441da61..60d76903 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -15,8 +15,7 @@ public final actor SyncEngine { let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] let foreignKeysByTableName: [String: [ForeignKey]] - var privateSyncEngine: (any SyncEngineProtocol)! - var sharedSyncEngine: (any SyncEngineProtocol)! + let syncEngines = LockIsolated(SyncEngines()) let defaultSyncEngines: (any DatabaseReader, SyncEngine) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) @@ -204,14 +203,17 @@ public final actor SyncEngine { try Metadata.createTriggers(tables: self.tables, db: db) for table in self.tables { - func open(_: T.Type) throws { - try T.createTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) - } - try open(table) + try table.createTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } } - (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) + let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) + self.syncEngines.withValue { + $0 = SyncEngines( + private: privateSyncEngine, + shared: sharedSyncEngine + ) + } let previousRecordTypes = try await metadatabase.read { db in try RecordType.all.fetchAll(db) @@ -237,29 +239,27 @@ public final actor SyncEngine { return existingRecordType.schema != currentRecordType.schema } if !recordTypesToFetch.isEmpty { - // TODO: Should we avoid this unstructured task by making 'setUpSyncEngine' async? -// Task { - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await metadatabase.write { db in - for recordType in recordTypesToFetch { - try RecordType.upsert(RecordType.Draft(recordType)).execute(db) - } + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await metadatabase.write { db in + for recordType in recordTypesToFetch { + try RecordType.upsert(RecordType.Draft(recordType)).execute(db) } } - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await fetchChanges() - } -// } + } + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await fetchChanges() + } } } - package func tearDownSyncEngine() throws { - try database.write { db in - for table in tables { - func open(_: T.Type) throws { - try dropTriggers(table: table, db: db) - } - try open(table) + package func tearDownSyncEngine() async throws { + let syncEngines = syncEngines.withValue(\.self) + await syncEngines.private?.cancelOperations() + await syncEngines.shared?.cancelOperations() + + try await database.write { db in + for table in self.tables { + try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } try Metadata.dropTriggers(db: db) db.remove(function: .willDelete(syncEngine: self)) @@ -268,29 +268,19 @@ public final actor SyncEngine { db.remove(function: .getZoneName) db.remove(function: .isUpdatingWithServerRecord) } - // try database.writeWithoutTransaction { db in - // try SQLQueryExpression( - // "DETACH DATABASE \(quote: .sqliteDataCloudKitSchemaName)" - // ) - // .execute(db) - // } - - // // TODO: Instead of deleting let's just empty the database. - // try metadatabase.close() - // try FileManager.default.removeItem(at: metadatabaseURL) - - try metadatabase.erase() + try await metadatabase.erase() } // TODO: resendAll() ? public func fetchChanges() async throws { - try await privateSyncEngine.fetchChanges() - try await sharedSyncEngine.fetchChanges() + let syncEngines = syncEngines.withValue(\.self) + try await syncEngines.private?.fetchChanges() + try await syncEngines.shared?.fetchChanges() } public func deleteLocalData() async throws { - try tearDownSyncEngine() + try await tearDownSyncEngine() withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in for table in tables { @@ -306,11 +296,10 @@ public final actor SyncEngine { try await setUpSyncEngine() } - func didUpdate(recordName: String, zoneName: String, ownerName: String) { - let syncEngine = - ownerName == Self.defaultZone.zoneID.ownerName - ? privateSyncEngine - : sharedSyncEngine + nonisolated func didUpdate(recordName: String, zoneName: String, ownerName: String) { + let syncEngine = syncEngines.withValue { + ownerName == Self.defaultZone.zoneID.ownerName ? $0.private : $0.shared + } syncEngine?.state.add( pendingRecordZoneChanges: [ .saveRecord( @@ -326,11 +315,10 @@ public final actor SyncEngine { ) } - func willDelete(recordName: String, zoneName: String, ownerName: String) { - let syncEngine = - ownerName == Self.defaultZone.zoneID.ownerName - ? privateSyncEngine - : sharedSyncEngine + nonisolated func willDelete(recordName: String, zoneName: String, ownerName: String) { + let syncEngine = syncEngines.withValue { + ownerName == Self.defaultZone.zoneID.ownerName ? $0.private : $0.shared + } syncEngine?.state.add( pendingRecordZoneChanges: [ .deleteRecord( @@ -370,17 +358,8 @@ public final actor SyncEngine { ) } } - - private func dropTriggers(table: T.Type, db: Database) throws { - let foreignKeys = foreignKeysByTableName[T.tableName] ?? [] - for foreignKey in foreignKeys { - try foreignKey.dropTriggers(for: T.self, db: db) - } - try Metadata.dropTriggers(for: T.self, db: db) - } } - extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) fileprivate static func createTriggers( @@ -388,9 +367,9 @@ extension PrimaryKeyedTable { db: Database ) throws { let foreignKey = - foreignKeysByTableName[Self.tableName]?.count(where: \.notnull) == 1 - ? foreignKeysByTableName[Self.tableName]?.first(where: \.notnull) - : nil + foreignKeysByTableName[Self.tableName]?.count(where: \.notnull) == 1 + ? foreignKeysByTableName[Self.tableName]?.first(where: \.notnull) + : nil try Metadata.createTriggers(for: Self.self, parentForeignKey: foreignKey, db: db) @@ -399,6 +378,18 @@ extension PrimaryKeyedTable { try foreignKey.createTriggers(for: Self.self, db: db) } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func dropTriggers( + foreignKeysByTableName: [String: [ForeignKey]], + db: Database + ) throws { + let foreignKeys = foreignKeysByTableName[Self.tableName] ?? [] + for foreignKey in foreignKeys { + try foreignKey.dropTriggers(for: Self.self, db: db) + } + try Metadata.dropTriggers(for: Self.self, db: db) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -562,7 +553,9 @@ extension SyncEngine: CKSyncEngineDelegate { private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) async { switch event.changeType { case .signIn: - privateSyncEngine.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) + syncEngines.withValue { + $0.private?.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) + } for table in tables { withErrorReporting(.sqliteDataCloudKitFailure) { let names: [String] = try database.read { db in @@ -573,16 +566,18 @@ extension SyncEngine: CKSyncEngineDelegate { } return try open(table) } - privateSyncEngine.state.add( - pendingRecordZoneChanges: names.map { - .saveRecord( - CKRecord.ID( - recordName: $0, - zoneID: Self.defaultZone.zoneID + syncEngines.withValue { + $0.private?.state.add( + pendingRecordZoneChanges: names.map { + .saveRecord( + CKRecord.ID( + recordName: $0, + zoneID: Self.defaultZone.zoneID + ) ) - ) - } - ) + } + ) + } } } case .signOut, .switchAccounts: @@ -884,7 +879,7 @@ extension SyncEngine: CKSyncEngineDelegate { extension DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { Self("didUpdate") { recordName, zoneName, ownerName in - await syncEngine + syncEngine .didUpdate( recordName: recordName, zoneName: zoneName, @@ -895,7 +890,7 @@ extension DatabaseFunction { fileprivate static func willDelete(syncEngine: SyncEngine) -> Self { return Self("willDelete") { recordName, zoneName, ownerName in - await syncEngine.willDelete( + syncEngine.willDelete( recordName: recordName, zoneName: zoneName, ownerName: ownerName @@ -924,7 +919,7 @@ extension DatabaseFunction { private convenience init( _ name: String, - function: @escaping @Sendable (String, String, String) async -> Void + function: @escaping @Sendable (String, String, String) -> Void ) { self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 3) { arguments in guard @@ -934,8 +929,7 @@ extension DatabaseFunction { else { return nil } - // TODO: can we get rid of task by making stuff in actor non-isolated? - Task { await function(recordName, zoneName, ownerName) } + function(recordName, zoneName, ownerName) return nil } } @@ -961,18 +955,31 @@ extension URL { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKContainer { - func shareMetadata( - for url: URL, - shouldFetchRootRecord: Bool = false - ) async throws -> CKShare.Metadata { - try await withUnsafeThrowingContinuation { continuation in - let operation = CKFetchShareMetadataOperation(shareURLs: [url]) - operation.shouldFetchRootRecord = true - operation.perShareMetadataResultBlock = { url, result in - continuation.resume(with: result) - } - add(operation) +struct SyncEngines { + let _private: (any SyncEngineProtocol)? + let _shared: (any SyncEngineProtocol)? + init() { + _private = nil + _shared = nil + } + init(private: any SyncEngineProtocol, shared: any SyncEngineProtocol) { + self._private = `private` + self._shared = shared + } + var `private`: (any SyncEngineProtocol)? { + guard let _private + else { + reportIssue("Private sync engine has not been set.") + return nil + } + return _private + } + var `shared`: (any SyncEngineProtocol)? { + guard let _shared + else { + reportIssue("Shared sync engine has not been set.") + return nil } + return _shared } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 5f03c08a..3e98c860 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -7,6 +7,7 @@ package protocol SyncEngineProtocol: AnyObject, Sendable { var state: State { get } var scope: CKDatabase.Scope { get } func acceptShare(metadata: ShareMetadata) async throws + func cancelOperations() async } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 03ff68c3..aa24a2fd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -65,7 +65,7 @@ extension BaseCloudKitTests { } try await syncEngine.tearDownSyncEngine() await #expect(throws: DatabaseError.self) { - try await database.write { db in + try await self.database.write { db in try Metadata.count().fetchOne(db) ?? 0 } } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 066a832d..d6c13d5a 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -74,6 +74,9 @@ final class MockSyncEngine: SyncEngineProtocol { $0.removeAll() } } + + func cancelOperations() async { + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From e486135056df3c2a3299955c8d39f72ed795888d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 3 Jun 2025 21:15:17 -0700 Subject: [PATCH 111/581] wip --- .../CloudKit/Metadatabase.swift | 84 ++++++++++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 105 +++--------------- .../CloudKit/SyncEngineProtocol+Live.swift | 7 +- .../CloudKitTests/CloudKitTests.swift | 10 +- 4 files changed, 113 insertions(+), 93 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/Metadatabase.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift new file mode 100644 index 00000000..502dc92c --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -0,0 +1,84 @@ +import Foundation +import os + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +func defaultMetadatabase( + logger: Logger, + url: URL +) throws -> any DatabaseWriter { + var configuration = Configuration() + configuration.prepareDatabase { [logger] db in + db.trace { + logger.trace("\($0.expandedDescription)") + } + } + logger.debug( + """ + Metadatabase connection: + open "\(url.path(percentEncoded: false))" + """ + ) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + let metadatabase = try DatabaseQueue( + path: url.path(percentEncoded: false), + configuration: configuration + ) + // TODO: go towards idempotent migrations instead of GRDB migrator by the end of all of this + var migrator = DatabaseMigrator() + // TODO: do we want this? + #if DEBUG + migrator.eraseDatabaseOnSchemaChange = true + #endif + migrator.registerMigration("Create Metadata Tables") { db in + // TODO: Should "recordName" be "collate no case"? + // TODO: should primary key be (recordType, recordName) so that we can use autoincrementing + // UUIDs in tests? + try SQLQueryExpression( + """ + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( + "recordType" TEXT NOT NULL, + "recordName" TEXT NOT NULL PRIMARY KEY, + "zoneName" TEXT NOT NULL, + "ownerName" TEXT NOT NULL, + "parentRecordName" TEXT, + "lastKnownServerRecord" BLOB, + "share" BLOB, + "userModificationDate" TEXT + ) STRICT + """ + ) + .execute(db) + // TODO: Should we have "parentRecordName TEXT REFERENCES metadata(recordName) ON DELETE CASCADE" ? + // TODO: Do we ever query for "parentRecordName"? should we add an index? + try SQLQueryExpression( + """ + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneName_ownerName" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ("zoneName", "ownerName") + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( + "tableName" TEXT NOT NULL PRIMARY KEY, + "schema" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( + "scope" TEXT NOT NULL PRIMARY KEY, + "data" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + } + try migrator.migrate(metadatabase) + return metadatabase +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 60d76903..b08f6396 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -10,8 +10,7 @@ public final actor SyncEngine { let database: any DatabaseWriter let logger: Logger - lazy var metadatabase: any DatabaseWriter = try! DatabaseQueue() - private let metadatabaseURL: URL + let metadatabase: any DatabaseWriter let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] let foreignKeysByTableName: [String: [ForeignKey]] @@ -96,7 +95,7 @@ public final actor SyncEngine { self.defaultSyncEngines = defaultSyncEngines self.database = database self.logger = logger - self.metadatabaseURL = metadatabaseURL + self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) self.tables = tables self.tablesByName = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = Dictionary( @@ -121,62 +120,6 @@ public final actor SyncEngine { } package func setUpSyncEngine() async throws { - metadatabase = try defaultMetadatabase - // TODO: go towards idempotent migrations instead of GRDB migrator by the end of all of this - var migrator = DatabaseMigrator() - // TODO: do we want this? - #if DEBUG - migrator.eraseDatabaseOnSchemaChange = true - #endif - migrator.registerMigration("Create Metadata Tables") { db in - // TODO: Should "recordName" be "collate no case"? - // TODO: should primary key be (recordType, recordName) so that we can use autoincrementing - // UUIDs in tests? - try SQLQueryExpression( - """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( - "recordType" TEXT NOT NULL, - "recordName" TEXT NOT NULL PRIMARY KEY, - "zoneName" TEXT NOT NULL, - "ownerName" TEXT NOT NULL, - "parentRecordName" TEXT, - "lastKnownServerRecord" BLOB, - "share" BLOB, - "userModificationDate" TEXT - ) STRICT - """ - ) - .execute(db) - // TODO: Should we have "parentRecordName TEXT REFERENCES metadata(recordName) ON DELETE CASCADE" ? - // TODO: Do we ever query for "parentRecordName"? should we add an index? - try SQLQueryExpression( - """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneName_ownerName" - ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ("zoneName", "ownerName") - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( - "tableName" TEXT NOT NULL PRIMARY KEY, - "schema" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( - "scope" TEXT NOT NULL PRIMARY KEY, - "data" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - } - try migrator.migrate(metadatabase) - try await database.write { db in let hasAttachedMetadatabase: Bool = try SQLQueryExpression( @@ -190,7 +133,9 @@ public final actor SyncEngine { .fetchOne(db) == 1 if !hasAttachedMetadatabase { try SQLQueryExpression( - "ATTACH DATABASE \(self.metadatabaseURL) AS \(quote: .sqliteDataCloudKitSchemaName)" + """ + ATTACH DATABASE \(bind: self.metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) + """ ) .execute(db) } @@ -215,6 +160,11 @@ public final actor SyncEngine { ) } + /* + TODO: When we detect a change in schema should save records? + TODO: Should we save records for everything in a table that is not in metadata? + */ + let previousRecordTypes = try await metadatabase.read { db in try RecordType.all.fetchAll(db) } @@ -268,7 +218,13 @@ public final actor SyncEngine { db.remove(function: .getZoneName) db.remove(function: .isUpdatingWithServerRecord) } - try await metadatabase.erase() + try await metadatabase.write { db in + // TODO: should we just loop through all tables and delete? + // We don't want to drop tables because then we have to re-migrate + try Metadata.delete().execute(db) + try RecordType.delete().execute(db) + try StateSerialization.delete().execute(db) + } } // TODO: resendAll() ? @@ -333,31 +289,6 @@ public final actor SyncEngine { ] ) } - - private var defaultMetadatabase: any DatabaseWriter { - get throws { - var configuration = Configuration() - configuration.prepareDatabase { [logger] db in - db.trace { - logger.trace("\($0.expandedDescription)") - } - } - logger.debug( - """ - Metadatabase connection: - open "\(self.metadatabaseURL.path(percentEncoded: false))" - """ - ) - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - return try DatabaseQueue( - path: metadatabaseURL.path(percentEncoded: false), - configuration: configuration - ) - } - } } extension PrimaryKeyedTable { @@ -558,7 +489,7 @@ extension SyncEngine: CKSyncEngineDelegate { } for table in tables { withErrorReporting(.sqliteDataCloudKitFailure) { - let names: [String] = try database.read { db in + let names = try database.read { db in func open(_: T.Type) throws -> [String] { try T .select { SQLQueryExpression("\($0.primaryKey)", as: String.self) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift index 7bd17b37..0064c546 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift @@ -12,11 +12,16 @@ extension CKSyncEngine: SyncEngineProtocol { reportIssue("TODO") return } + guard let rootRecordID = metadata.hierarchicalRootRecordID + else { + reportIssue("TODO") + return + } let container = CKContainer(identifier: metadata.containerIdentifier) try await container.accept(metadata) try await fetchChanges( .init( - scope: .zoneIDs([metadata.hierarchicalRootRecordID!.zoneID]), + scope: .zoneIDs([rootRecordID.zoneID]), operationGroup: nil ) ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index aa24a2fd..a643ad3a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -64,11 +64,11 @@ extension BaseCloudKitTests { try Metadata.count().fetchOne(db) ?? 0 } try await syncEngine.tearDownSyncEngine() - await #expect(throws: DatabaseError.self) { - try await self.database.write { db in - try Metadata.count().fetchOne(db) ?? 0 - } - } +// await #expect(throws: DatabaseError.self) { +// try await self.database.write { db in +// try Metadata.count().fetchOne(db) ?? 0 +// } +// } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From a59651c21c68f20ea7144384ab4292ec79601e75 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 6 Jun 2025 13:48:54 -0700 Subject: [PATCH 112/581] drop actor --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b08f6396..188d06de 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -3,7 +3,7 @@ import ConcurrencyExtras import OSLog @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -public final actor SyncEngine { +public final class SyncEngine: Sendable { public static nonisolated let defaultZone = CKRecordZone( zoneName: "co.pointfree.SQLiteData.defaultZone" ) @@ -16,11 +16,11 @@ public final actor SyncEngine { let foreignKeysByTableName: [String: [ForeignKey]] let syncEngines = LockIsolated(SyncEngines()) let defaultSyncEngines: - (any DatabaseReader, SyncEngine) + @Sendable (any DatabaseReader, SyncEngine) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) let _container: any Sendable - public init( + public convenience init( container: CKContainer, database: any DatabaseWriter, logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), @@ -57,7 +57,7 @@ public final actor SyncEngine { ) } - package init( + package convenience init( privateSyncEngine: any SyncEngineProtocol, sharedSyncEngine: any SyncEngineProtocol, database: any DatabaseWriter, @@ -75,7 +75,7 @@ public final actor SyncEngine { private init( container: (any Sendable)? = Void?.none, - defaultSyncEngines: @escaping ( + defaultSyncEngines: @escaping @Sendable ( any DatabaseReader, SyncEngine ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), @@ -115,11 +115,12 @@ public final actor SyncEngine { } } - nonisolated var container: CKContainer { + var container: CKContainer { _container as! CKContainer } package func setUpSyncEngine() async throws { + // TODO: SHould we wrap these database calls in `{ … }()` to avoid await? try await database.write { db in let hasAttachedMetadatabase: Bool = try SQLQueryExpression( @@ -204,8 +205,8 @@ public final actor SyncEngine { package func tearDownSyncEngine() async throws { let syncEngines = syncEngines.withValue(\.self) - await syncEngines.private?.cancelOperations() - await syncEngines.shared?.cancelOperations() + async let privateCancellation: Void? = syncEngines.private?.cancelOperations() + async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() try await database.write { db in for table in self.tables { @@ -219,12 +220,13 @@ public final actor SyncEngine { db.remove(function: .isUpdatingWithServerRecord) } try await metadatabase.write { db in - // TODO: should we just loop through all tables and delete? - // We don't want to drop tables because then we have to re-migrate + // TODO: Do an `.erase()` + re-migrate try Metadata.delete().execute(db) try RecordType.delete().execute(db) try StateSerialization.delete().execute(db) } + + _ = await (privateCancellation, sharedCancellation) } // TODO: resendAll() ? @@ -252,7 +254,7 @@ public final actor SyncEngine { try await setUpSyncEngine() } - nonisolated func didUpdate(recordName: String, zoneName: String, ownerName: String) { + func didUpdate(recordName: String, zoneName: String, ownerName: String) { let syncEngine = syncEngines.withValue { ownerName == Self.defaultZone.zoneID.ownerName ? $0.private : $0.shared } @@ -271,7 +273,7 @@ public final actor SyncEngine { ) } - nonisolated func willDelete(recordName: String, zoneName: String, ownerName: String) { + func willDelete(recordName: String, zoneName: String, ownerName: String) { let syncEngine = syncEngines.withValue { ownerName == Self.defaultZone.zoneID.ownerName ? $0.private : $0.shared } From 122df7bc882687042b748dede271afe5644d52eb Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 7 Jun 2025 12:53:52 -0700 Subject: [PATCH 113/581] more library clean up dont drop triggers, just create if not exist --- Package.resolved | 20 +- .../SharingGRDBCore/CloudKit/ForeignKey.swift | 176 ++--- .../SharingGRDBCore/CloudKit/Metadata.swift | 118 +-- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 8 +- .../CloudKitTests/TriggerTests.swift | 691 +++++++++++++++--- 5 files changed, 756 insertions(+), 257 deletions(-) diff --git a/Package.resolved b/Package.resolved index f274d912..da90bdc7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "794a047bafc2275bf9dfcec9ec08b24621f054b9b331fc8dbfec924f4d5eb72c", + "originHash" : "0a9d215bd95753f1b1f21ad3580ad7325b2a80e8876732095c77b2454586e3b9", "pins" : [ { "identity" : "combine-schedulers", @@ -19,6 +19,15 @@ "version" : "7.4.1" } }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", + "version" : "1.7.0" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -91,6 +100,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", + "version" : "2.3.0" + } + }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index f0f01fc1..43e19b65 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -62,7 +62,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { case .cascade: try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteCascade" AFTER DELETE ON \(quote: table) FOR EACH ROW BEGIN @@ -76,7 +76,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { case .restrict: try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteRestrict" AFTER DELETE ON \(quote: table) FOR EACH ROW BEGIN @@ -102,7 +102,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetDefault" AFTER DELETE ON \(quote: table) FOR EACH ROW BEGIN @@ -117,7 +117,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { case .setNull: try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetNull" AFTER DELETE ON \(quote: table) FOR EACH ROW BEGIN @@ -136,7 +136,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { case .cascade: try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateCascade" AFTER UPDATE ON \(quote: table) FOR EACH ROW BEGIN @@ -151,7 +151,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { case .restrict: try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateRestrict" AFTER UPDATE ON \(quote: table) FOR EACH ROW BEGIN @@ -177,7 +177,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetDefault" AFTER UPDATE ON \(quote: table) FOR EACH ROW BEGIN @@ -192,7 +192,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { case .setNull: try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER + CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetNull" AFTER UPDATE ON \(quote: table) FOR EACH ROW BEGIN @@ -209,86 +209,86 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { } func dropTriggers(for _: T.Type, db: Database) throws { - switch onDelete { - case .cascade: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteCascade" - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetNull" - """ - ) - .execute(db) - - case .setDefault: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetDefault" - """ - ) - .execute(db) - - case .restrict: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteRestrict" - """ - ) - .execute(db) - - case .noAction: - break - } +// switch onDelete { +// case .cascade: +// try SQLQueryExpression( +// """ +// DROP TRIGGER +// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteCascade" +// """ +// ) +// .execute(db) +// +// case .setNull: +// try SQLQueryExpression( +// """ +// DROP TRIGGER +// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetNull" +// """ +// ) +// .execute(db) +// +// case .setDefault: +// try SQLQueryExpression( +// """ +// DROP TRIGGER +// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetDefault" +// """ +// ) +// .execute(db) +// +// case .restrict: +// try SQLQueryExpression( +// """ +// DROP TRIGGER +// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteRestrict" +// """ +// ) +// .execute(db) +// +// case .noAction: +// break +// } - switch onUpdate { - case .cascade: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateCascade" - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetNull" - """ - ) - .execute(db) - - case .setDefault: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetDefault" - """ - ) - .execute(db) - - case .restrict: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateRestrict" - """ - ) - .execute(db) - - case .noAction: - break - } +// switch onUpdate { +// case .cascade: +// try SQLQueryExpression( +// """ +// DROP TRIGGER +// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateCascade" +// """ +// ) +// .execute(db) +// +// case .setNull: +// try SQLQueryExpression( +// """ +// DROP TRIGGER +// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetNull" +// """ +// ) +// .execute(db) +// +// case .setDefault: +// try SQLQueryExpression( +// """ +// DROP TRIGGER +// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetDefault" +// """ +// ) +// .execute(db) +// +// case .restrict: +// try SQLQueryExpression( +// """ +// DROP TRIGGER +// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateRestrict" +// """ +// ) +// .execute(db) +// +// case .noAction: +// break +// } } } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 4eeb332b..393b1646 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -9,17 +9,18 @@ extension Metadata { ) throws { try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_inserts" + CREATE TEMPORARY TRIGGER IF NOT EXISTS + "\(raw: .sqliteDataCloudKitSchemaName)_metadata_inserts" AFTER INSERT ON \(Metadata.self) - FOR EACH ROW + FOR EACH ROW + WHEN NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() BEGIN SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate( "new"."recordName", "new"."zoneName", "new"."ownerName" - ) - WHERE NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord(); + ); END """ ) @@ -27,35 +28,36 @@ extension Metadata { try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_updates" + CREATE TEMPORARY TRIGGER IF NOT EXISTS + "\(raw: .sqliteDataCloudKitSchemaName)_metadata_updates" AFTER UPDATE ON \(Metadata.self) - FOR EACH ROW + FOR EACH ROW + WHEN NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() BEGIN SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate( "new"."recordName", "new"."zoneName", "new"."ownerName" - ) - WHERE NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() - ; + ); END """ ) .execute(db) try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_deletes" + CREATE TEMPORARY TRIGGER IF NOT EXISTS + "\(raw: .sqliteDataCloudKitSchemaName)_metadata_deletes" BEFORE DELETE ON \(Metadata.self) - FOR EACH ROW + FOR EACH ROW + WHEN NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() BEGIN SELECT \(raw: .sqliteDataCloudKitSchemaName)_willDelete( "old"."recordName", "old"."zoneName", "old"."ownerName" - ) - WHERE NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord(); + ); END """ ) @@ -63,9 +65,9 @@ extension Metadata { } static func dropTriggers(db: Database) throws { - try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_deletes""#).execute(db) - try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_updates""#).execute(db) - try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_inserts""#).execute(db) +// try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_deletes""#).execute(db) +// try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_updates""#).execute(db) +// try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_inserts""#).execute(db) } static func createTriggers( @@ -75,58 +77,64 @@ extension Metadata { ) throws { let foreignKey = (parentForeignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" + let upsert: QueryFragment = """ + INSERT INTO \(Metadata.self) + ( + \(quote: Metadata.recordType.name), + \(quote: Metadata.recordName.name), + \(quote: Metadata.zoneName.name), + \(quote: Metadata.ownerName.name), + \(quote: Metadata.parentRecordName.name), + \(quote: Metadata.userModificationDate.name) + ) + SELECT + \(quote: T.tableName, delimiter: .text), + "new".\(quote: T.columns.primaryKey.name), + coalesce( + \(Metadata.zoneName), + \(raw: .sqliteDataCloudKitSchemaName)_getZoneName(), + \(quote: SyncEngine.defaultZone.zoneID.zoneName, delimiter: .text) + ), + coalesce( + \(Metadata.ownerName), + \(raw: .sqliteDataCloudKitSchemaName)_getOwnerName(), + \(quote: SyncEngine.defaultZone.zoneID.ownerName, delimiter: .text) + ), + \(raw: foreignKey) AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN \(Metadata.self) ON \(Metadata.recordName) = "foreignKey" + ON CONFLICT(\(quote: Metadata.recordName.name)) DO UPDATE + SET + \(quote: Metadata.recordType.name) = "excluded".\(quote: Metadata.recordType.name), + \(quote: Metadata.zoneName.name) = "excluded".\(quote: Metadata.zoneName.name), + \(quote: Metadata.ownerName.name) = "excluded".\(quote: Metadata.ownerName.name), + \(quote: Metadata.parentRecordName.name) = "excluded".\(quote: Metadata.parentRecordName.name), + \(quote: Metadata.recordType.name) = "excluded".\(quote: Metadata.recordType.name), + \(quote: Metadata.userModificationDate.name) = "excluded".\(quote: Metadata.userModificationDate.name) + """ + try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER \(Self.insertTriggerName(for: T.self)) + CREATE TEMPORARY TRIGGER IF NOT EXISTS \(Self.insertTriggerName(for: T.self)) AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN - INSERT INTO \(Metadata.self) - ( - \(quote: Metadata.recordType.name), - \(quote: Metadata.recordName.name), - \(quote: Metadata.zoneName.name), - \(quote: Metadata.ownerName.name), - \(quote: Metadata.parentRecordName.name), - \(quote: Metadata.userModificationDate.name) - ) - SELECT - \(quote: T.tableName, delimiter: .text), - "new".\(quote: T.columns.primaryKey.name), - coalesce( - \(Metadata.zoneName), - \(raw: .sqliteDataCloudKitSchemaName)_getZoneName(), - \(quote: SyncEngine.defaultZone.zoneID.zoneName, delimiter: .text) - ), - coalesce( - \(Metadata.ownerName), - \(raw: .sqliteDataCloudKitSchemaName)_getOwnerName(), - \(quote: SyncEngine.defaultZone.zoneID.ownerName, delimiter: .text) - ), - \(raw: foreignKey) AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN \(Metadata.self) ON \(Metadata.recordName) = "foreignKey" - ON CONFLICT("recordName") DO NOTHING; + \(upsert); END """ ) .execute(db) try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER \(Self.updateTriggerName(for: T.self)) + CREATE TEMPORARY TRIGGER IF NOT EXISTS \(Self.updateTriggerName(for: T.self)) AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN - UPDATE \(Metadata.self) - SET - "recordName" = "new".\(quote: T.columns.primaryKey.name), - "userModificationDate" = datetime('subsec'), - "parentRecordName" = \(raw: foreignKey) - WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name); + \(upsert); END """ ) .execute(db) try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER \(Self.deleteTriggerName(for: T.self)) + CREATE TEMPORARY TRIGGER IF NOT EXISTS \(Self.deleteTriggerName(for: T.self)) AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN DELETE FROM \(Metadata.self) WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name); @@ -140,9 +148,9 @@ extension Metadata { for _: T.Type, db: Database ) throws { - try SQLQueryExpression("DROP TRIGGER \(Self.deleteTriggerName(for: T.self))").execute(db) - try SQLQueryExpression("DROP TRIGGER \(Self.updateTriggerName(for: T.self))").execute(db) - try SQLQueryExpression("DROP TRIGGER \(Self.insertTriggerName(for: T.self))").execute(db) +// try SQLQueryExpression("DROP TRIGGER \(Self.deleteTriggerName(for: T.self))").execute(db) +// try SQLQueryExpression("DROP TRIGGER \(Self.updateTriggerName(for: T.self))").execute(db) +// try SQLQueryExpression("DROP TRIGGER \(Self.insertTriggerName(for: T.self))").execute(db) } private static func insertTriggerName( diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 188d06de..e7d729a3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -428,7 +428,7 @@ extension SyncEngine: CKSyncEngineDelegate { } #endif - guard let metadata = await metadataFor(recordID: recordID) + guard let metadata = metadataFor(recordID: recordID) else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) return nil @@ -474,7 +474,7 @@ extension SyncEngine: CKSyncEngineDelegate { with: T(queryOutput: row), userModificationDate: metadata.userModificationDate ) - await refreshLastKnownServerRecord(record) + refreshLastKnownServerRecord(record) sentRecord = recordID return record } @@ -575,8 +575,8 @@ extension SyncEngine: CKSyncEngineDelegate { try await self.cacheShare(share) } } else { - await self.upsertFromServerRecord(record) - await self.refreshLastKnownServerRecord(record) + self.upsertFromServerRecord(record) + self.refreshLastKnownServerRecord(record) } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 16e6f962..8e8bc77c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -18,87 +18,121 @@ extension BaseCloudKitTests { [0]: """ CREATE TRIGGER "sqlitedata_icloud_metadata_inserts" AFTER INSERT ON "sqlitedata_icloud_metadata" - FOR EACH ROW + FOR EACH ROW + WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT sqlitedata_icloud_didUpdate( "new"."recordName", "new"."zoneName", "new"."ownerName" - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); + ); END """, [1]: """ CREATE TRIGGER "sqlitedata_icloud_metadata_updates" AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW + FOR EACH ROW + WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT sqlitedata_icloud_didUpdate( "new"."recordName", "new"."zoneName", "new"."ownerName" - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord() - ; + ); END """, [2]: """ CREATE TRIGGER "sqlitedata_icloud_metadata_deletes" BEFORE DELETE ON "sqlitedata_icloud_metadata" - FOR EACH ROW + FOR EACH ROW + WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT sqlitedata_icloud_willDelete( "old"."recordName", "old"."zoneName", "old"."ownerName" - ) - WHERE NOT sqlitedata_icloud_isUpdatingWithServerRecord(); + ); END """, [3]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_metadataInserts" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "zoneName", - "ownerName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'reminders', - "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), - "new"."remindersListID" AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" - ON CONFLICT("recordName") DO NOTHING; + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'reminders', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + "new"."remindersListID" AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; END """, [4]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_metadataUpdates" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - UPDATE "sqlitedata_icloud_metadata" - SET - "recordName" = "new"."id", - "userModificationDate" = datetime('subsec'), - "parentRecordName" = "new"."remindersListID" - WHERE "recordName" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'reminders', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + "new"."remindersListID" AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; END """, [5]: """ @@ -165,43 +199,78 @@ extension BaseCloudKitTests { CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataInserts" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "zoneName", - "ownerName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'remindersLists', - "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), - NULL AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" - ON CONFLICT("recordName") DO NOTHING; + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'remindersLists', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; END """, [13]: """ CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataUpdates" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - UPDATE "sqlitedata_icloud_metadata" - SET - "recordName" = "new"."id", - "userModificationDate" = datetime('subsec'), - "parentRecordName" = NULL - WHERE "recordName" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'remindersLists', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; END """, [14]: """ @@ -215,43 +284,78 @@ extension BaseCloudKitTests { CREATE TRIGGER "sqlitedata_icloud_users_metadataInserts" AFTER INSERT ON "users" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "zoneName", - "ownerName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'users', - "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), - NULL AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" - ON CONFLICT("recordName") DO NOTHING; + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'users', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; END """, [16]: """ CREATE TRIGGER "sqlitedata_icloud_users_metadataUpdates" AFTER UPDATE ON "users" FOR EACH ROW BEGIN - UPDATE "sqlitedata_icloud_metadata" - SET - "recordName" = "new"."id", - "userModificationDate" = datetime('subsec'), - "parentRecordName" = NULL - WHERE "recordName" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'users', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; END """, [17]: """ @@ -288,9 +392,378 @@ extension BaseCloudKitTests { try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } assertInlineSnapshot(of: triggersAfterTearDown, as: .customDump) { - """ - [] - """ + #""" + [ + [0]: """ + CREATE TRIGGER "sqlitedata_icloud_metadata_inserts" + AFTER INSERT ON "sqlitedata_icloud_metadata" + FOR EACH ROW + WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() + BEGIN + SELECT + sqlitedata_icloud_didUpdate( + "new"."recordName", + "new"."zoneName", + "new"."ownerName" + ); + END + """, + [1]: """ + CREATE TRIGGER "sqlitedata_icloud_metadata_updates" + AFTER UPDATE ON "sqlitedata_icloud_metadata" + FOR EACH ROW + WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() + BEGIN + SELECT + sqlitedata_icloud_didUpdate( + "new"."recordName", + "new"."zoneName", + "new"."ownerName" + ); + END + """, + [2]: """ + CREATE TRIGGER "sqlitedata_icloud_metadata_deletes" + BEFORE DELETE ON "sqlitedata_icloud_metadata" + FOR EACH ROW + WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() + BEGIN + SELECT + sqlitedata_icloud_willDelete( + "old"."recordName", + "old"."zoneName", + "old"."ownerName" + ); + END + """, + [3]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_metadataInserts" + AFTER INSERT ON "reminders" FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'reminders', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + "new"."remindersListID" AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [4]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_metadataUpdates" + AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'reminders', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + "new"."remindersListID" AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [5]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_metadataDeletes" + AFTER DELETE ON "reminders" FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE "recordName" = "old"."id"; + END + """, + [6]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" + AFTER DELETE ON "remindersLists" + FOR EACH ROW BEGIN + DELETE FROM "reminders" + WHERE "remindersListID" = "old"."id"; + END + """, + [7]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "remindersListID" = "new"."id" + WHERE "remindersListID" = "old"."id"; + END + """, + [8]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onDeleteRestrict" + AFTER DELETE ON "reminders" + FOR EACH ROW BEGIN + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "reminders" + WHERE "parentReminderID" = "old"."id"; + END + """, + [9]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onUpdateRestrict" + AFTER UPDATE ON "reminders" + FOR EACH ROW BEGIN + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "reminders" + WHERE "parentReminderID" = "old"."id"; + END + """, + [10]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_users_onDeleteSetNull" + AFTER DELETE ON "users" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "assignedUserID" = NULL + WHERE "assignedUserID" = "old"."id"; + END + """, + [11]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_users_onUpdateCascade" + AFTER UPDATE ON "users" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "assignedUserID" = "new"."id" + WHERE "assignedUserID" = "old"."id"; + END + """, + [12]: """ + CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataInserts" + AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'remindersLists', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [13]: """ + CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataUpdates" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'remindersLists', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [14]: """ + CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataDeletes" + AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE "recordName" = "old"."id"; + END + """, + [15]: """ + CREATE TRIGGER "sqlitedata_icloud_users_metadataInserts" + AFTER INSERT ON "users" FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'users', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [16]: """ + CREATE TRIGGER "sqlitedata_icloud_users_metadataUpdates" + AFTER UPDATE ON "users" FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ( + "recordType", + "recordName", + "zoneName", + "ownerName", + "parentRecordName", + "userModificationDate" + ) + SELECT + 'users', + "new"."id", + coalesce( + "sqlitedata_icloud_metadata"."zoneName", + sqlitedata_icloud_getZoneName(), + 'co.pointfree.SQLiteData.defaultZone' + ), + coalesce( + "sqlitedata_icloud_metadata"."ownerName", + sqlitedata_icloud_getOwnerName(), + '__defaultOwner__' + ), + NULL AS "foreignKey", + datetime('subsec') + FROM (SELECT 1) + LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" + ON CONFLICT("recordName") DO UPDATE + SET + "recordType" = "excluded"."recordType", + "zoneName" = "excluded"."zoneName", + "ownerName" = "excluded"."ownerName", + "parentRecordName" = "excluded"."parentRecordName", + "recordType" = "excluded"."recordType", + "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [17]: """ + CREATE TRIGGER "sqlitedata_icloud_users_metadataDeletes" + AFTER DELETE ON "users" FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE "recordName" = "old"."id"; + END + """, + [18]: """ + CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" + AFTER DELETE ON "users" + FOR EACH ROW BEGIN + UPDATE "users" + SET "parentUserID" = NULL + WHERE "parentUserID" = "old"."id"; + END + """, + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" + AFTER UPDATE ON "users" + FOR EACH ROW BEGIN + UPDATE "users" + SET "parentUserID" = "new"."id" + WHERE "parentUserID" = "old"."id"; + END + """ + ] + """# } try await syncEngine.setUpSyncEngine() From 07e4f0193071a9a855f5463a948b25c330a7f048 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 7 Jun 2025 12:53:45 -0700 Subject: [PATCH 114/581] new example app --- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 85 ++++++ .../Assets.xcassets/Contents.json | 6 + .../CloudKitDemo/CloudKitDemo.entitlements | 22 ++ Examples/CloudKitDemo/CloudKitDemoApp.swift | 26 ++ .../CloudKitDemo/CountersListFeature.swift | 70 +++++ Examples/CloudKitDemo/Info.plist | 12 + Examples/CloudKitDemo/Schema.swift | 34 +++ .../CloudKitDemoTests/CloudKitDemoTests.swift | 16 + Examples/Examples.xcodeproj/project.pbxproj | 285 +++++++++++++++++- 10 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 Examples/CloudKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/CloudKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/CloudKitDemo/Assets.xcassets/Contents.json create mode 100644 Examples/CloudKitDemo/CloudKitDemo.entitlements create mode 100644 Examples/CloudKitDemo/CloudKitDemoApp.swift create mode 100644 Examples/CloudKitDemo/CountersListFeature.swift create mode 100644 Examples/CloudKitDemo/Info.plist create mode 100644 Examples/CloudKitDemo/Schema.swift create mode 100644 Examples/CloudKitDemoTests/CloudKitDemoTests.swift diff --git a/Examples/CloudKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/CloudKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Examples/CloudKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CloudKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/CloudKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..ffdfe150 --- /dev/null +++ b/Examples/CloudKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,85 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CloudKitDemo/Assets.xcassets/Contents.json b/Examples/CloudKitDemo/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/CloudKitDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CloudKitDemo/CloudKitDemo.entitlements b/Examples/CloudKitDemo/CloudKitDemo.entitlements new file mode 100644 index 00000000..306c5a99 --- /dev/null +++ b/Examples/CloudKitDemo/CloudKitDemo.entitlements @@ -0,0 +1,22 @@ + + + + + aps-environment + development + com.apple.developer.aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.co.pointfree.SharingGRDB.CloudKitDemo + + com.apple.developer.icloud-services + + CloudKit + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift new file mode 100644 index 00000000..480f1a09 --- /dev/null +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -0,0 +1,26 @@ +import CloudKit +import SharingGRDB +import SwiftUI + +@main +struct CloudKitDemoApp: App { + init() { + try! prepareDependencies { + $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + container: CKContainer( + identifier: "iCloud.co.pointfree.SharingGRDB.CloudKitDemo" + ), + database: $0.defaultDatabase, + tables: [Counter.self] + ) + } + } + var body: some Scene { + WindowGroup { + NavigationStack { + CountersListView() + } + } + } +} diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift new file mode 100644 index 00000000..04ebbadb --- /dev/null +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -0,0 +1,70 @@ +import SharingGRDB +import SwiftUI + +struct CountersListView: View { + let parentCounter: Counter? + @FetchAll var counters: [Counter] + @Dependency(\.defaultDatabase) var database + + init(parentCounter: Counter? = nil) { + self.parentCounter = parentCounter + _counters = FetchAll(Counter.where { $0.parentCounterID.is(parentCounter?.id) }) + } + + var body: some View { + List { + ForEach(counters) { counter in + HStack { + NavigationLink { + CountersListView(parentCounter: counter) + } label: { + Text("\(counter.count)") + } + Button("-") { + withErrorReporting { + try database.write { db in + try Counter.find(counter.id).update { + $0.count -= 1 + } + .execute(db) + } + } + } + Button("+") { + withErrorReporting { + try database.write { db in + try Counter.find(counter.id).update { + $0.count += 1 + } + .execute(db) + } + } + } + } + .buttonStyle(.borderless) + } + .onDelete { indexSet in + withErrorReporting { + try database.write { db in + for index in indexSet { + try Counter.find(counters[index].id).delete() + .execute(db) + } + } + } + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { + withErrorReporting { + try database.write { db in + try Counter.insert(Counter.Draft(parentCounterID: parentCounter?.id)) + .execute(db) + } + } + } + } + } + } +} diff --git a/Examples/CloudKitDemo/Info.plist b/Examples/CloudKitDemo/Info.plist new file mode 100644 index 00000000..3d390066 --- /dev/null +++ b/Examples/CloudKitDemo/Info.plist @@ -0,0 +1,12 @@ + + + + + Bac + + UIBackgroundModes + + remote-notification + + + diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift new file mode 100644 index 00000000..d2b7f841 --- /dev/null +++ b/Examples/CloudKitDemo/Schema.swift @@ -0,0 +1,34 @@ +import Foundation +import SharingGRDB + +@Table +struct Counter: Identifiable { + let id: UUID + var count = 0 + var parentCounterID: Counter.ID? +} + +func appDatabase() throws -> any DatabaseWriter { + let path = URL.documentsDirectory.appendingPathComponent("db.sqlite").path() + var configuration = Configuration() + configuration.foreignKeysEnabled = false + let database = try DatabasePool(path: path, configuration: configuration) + + var migrator = DatabaseMigrator() + #if DEBUG + migrator.eraseDatabaseOnSchemaChange = true + #endif + migrator.registerMigration("Create tables") { db in + try #sql(""" + CREATE TABLE "counters" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "count" INT NOT NULL DEFAULT 0, + "parentCounterID" TEXT REFERENCES "counters"("id") ON DELETE CASCADE + ) + """) + .execute(db) + } + try migrator.migrate(database) + + return database +} diff --git a/Examples/CloudKitDemoTests/CloudKitDemoTests.swift b/Examples/CloudKitDemoTests/CloudKitDemoTests.swift new file mode 100644 index 00000000..291bacb3 --- /dev/null +++ b/Examples/CloudKitDemoTests/CloudKitDemoTests.swift @@ -0,0 +1,16 @@ +// +// CloudKitDemoTests.swift +// CloudKitDemoTests +// +// Created by Brandon Williams on 6/6/25. +// + +import Testing + +struct CloudKitDemoTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 43a6acc3..fd5ff907 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + CA1146CA2DF38D1D0054BA77 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA1146C92DF38D1D0054BA77 /* SharingGRDB */; }; CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA5E42502DE7C4D50069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E424F2DE7C4D50069E0F8 /* SwiftUINavigation */; }; @@ -20,6 +21,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + CA1146AD2DF38D000054BA77 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CAF836902D4735620047AEB5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = CA11469E2DF38CFE0054BA77; + remoteInfo = CloudKitDemo; + }; CAD001812D874E6F00FA977A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CAF836902D4735620047AEB5 /* Project object */; @@ -37,6 +45,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudKitDemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CA5F37542D5AFBBC002E1A9E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836982D4735620047AEB5 /* CaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -46,6 +56,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + CA1146CE2DF397DB0054BA77 /* Exceptions for "CloudKitDemo" folder in "CloudKitDemo" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = CA11469E2DF38CFE0054BA77 /* CloudKitDemo */; + }; CAD4819A2D584B510004799A /* Exceptions for "CaseStudies" folder in "CaseStudies" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -72,6 +89,19 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + CA1146A02DF38CFE0054BA77 /* CloudKitDemo */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CA1146CE2DF397DB0054BA77 /* Exceptions for "CloudKitDemo" folder in "CloudKitDemo" target */, + ); + path = CloudKitDemo; + sourceTree = ""; + }; + CA1146AF2DF38D000054BA77 /* CloudKitDemoTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CloudKitDemoTests; + sourceTree = ""; + }; CAD0017E2D874E6F00FA977A /* SyncUpTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = SyncUpTests; @@ -109,6 +139,21 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + CA11469C2DF38CFE0054BA77 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CA1146CA2DF38D1D0054BA77 /* SharingGRDB in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CA1146A92DF38D000054BA77 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD0017A2D874E6F00FA977A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -166,6 +211,8 @@ CAF836D92D4735AB0047AEB5 /* Reminders */, DCBE89CD2D483FB90071F499 /* SyncUps */, CAD0017E2D874E6F00FA977A /* SyncUpTests */, + CA1146A02DF38CFE0054BA77 /* CloudKitDemo */, + CA1146AF2DF38D000054BA77 /* CloudKitDemoTests */, CAF837022D4735C00047AEB5 /* Frameworks */, CAF836992D4735620047AEB5 /* Products */, ); @@ -179,6 +226,8 @@ CAF836D82D4735AB0047AEB5 /* Reminders.app */, DCBE89CC2D483FB90071F499 /* SyncUps.app */, CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */, + CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */, + CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */, ); name = Products; sourceTree = ""; @@ -193,6 +242,52 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + CA11469E2DF38CFE0054BA77 /* CloudKitDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA1146C42DF38D000054BA77 /* Build configuration list for PBXNativeTarget "CloudKitDemo" */; + buildPhases = ( + CA11469B2DF38CFE0054BA77 /* Sources */, + CA11469C2DF38CFE0054BA77 /* Frameworks */, + CA11469D2DF38CFE0054BA77 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + CA1146A02DF38CFE0054BA77 /* CloudKitDemo */, + ); + name = CloudKitDemo; + packageProductDependencies = ( + CA1146C92DF38D1D0054BA77 /* SharingGRDB */, + ); + productName = CloudKitDemo; + productReference = CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */; + productType = "com.apple.product-type.application"; + }; + CA1146AB2DF38D000054BA77 /* CloudKitDemoTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA1146C52DF38D000054BA77 /* Build configuration list for PBXNativeTarget "CloudKitDemoTests" */; + buildPhases = ( + CA1146A82DF38D000054BA77 /* Sources */, + CA1146A92DF38D000054BA77 /* Frameworks */, + CA1146AA2DF38D000054BA77 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CA1146AE2DF38D000054BA77 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + CA1146AF2DF38D000054BA77 /* CloudKitDemoTests */, + ); + name = CloudKitDemoTests; + packageProductDependencies = ( + ); + productName = CloudKitDemoTests; + productReference = CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; CAD0017C2D874E6F00FA977A /* SyncUpTests */ = { isa = PBXNativeTarget; buildConfigurationList = CAD001852D874E6F00FA977A /* Build configuration list for PBXNativeTarget "SyncUpTests" */; @@ -322,9 +417,16 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1630; + LastSwiftUpdateCheck = 1640; LastUpgradeCheck = 1620; TargetAttributes = { + CA11469E2DF38CFE0054BA77 = { + CreatedOnToolsVersion = 16.4; + }; + CA1146AB2DF38D000054BA77 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = CA11469E2DF38CFE0054BA77; + }; CAD0017C2D874E6F00FA977A = { CreatedOnToolsVersion = 16.3; TestTargetID = DCBE89CB2D483FB90071F499; @@ -368,11 +470,27 @@ CAF836D72D4735AB0047AEB5 /* Reminders */, DCBE89CB2D483FB90071F499 /* SyncUps */, CAD0017C2D874E6F00FA977A /* SyncUpTests */, + CA11469E2DF38CFE0054BA77 /* CloudKitDemo */, + CA1146AB2DF38D000054BA77 /* CloudKitDemoTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + CA11469D2DF38CFE0054BA77 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CA1146AA2DF38D000054BA77 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD0017B2D874E6F00FA977A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -411,6 +529,20 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + CA11469B2DF38CFE0054BA77 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CA1146A82DF38D000054BA77 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD001792D874E6F00FA977A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -449,6 +581,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + CA1146AE2DF38D000054BA77 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CA11469E2DF38CFE0054BA77 /* CloudKitDemo */; + targetProxy = CA1146AD2DF38D000054BA77 /* PBXContainerItemProxy */; + }; CAD001822D874E6F00FA977A /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DCBE89CB2D483FB90071F499 /* SyncUps */; @@ -462,6 +599,130 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + CA1146BE2DF38D000054BA77 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CloudKitDemo/CloudKitDemo.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CloudKitDemo/Info.plist; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 15.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Debug; + }; + CA1146BF2DF38D000054BA77 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CloudKitDemo/CloudKitDemo.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CloudKitDemo/Info.plist; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 15.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Release; + }; + CA1146C02DF38D000054BA77 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MACOSX_DEPLOYMENT_TARGET = 15.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemoTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CloudKitDemo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CloudKitDemo"; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Debug; + }; + CA1146C12DF38D000054BA77 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MACOSX_DEPLOYMENT_TARGET = 15.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemoTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CloudKitDemo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CloudKitDemo"; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Release; + }; CAD001832D874E6F00FA977A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -828,6 +1089,24 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + CA1146C42DF38D000054BA77 /* Build configuration list for PBXNativeTarget "CloudKitDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA1146BE2DF38D000054BA77 /* Debug */, + CA1146BF2DF38D000054BA77 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CA1146C52DF38D000054BA77 /* Build configuration list for PBXNativeTarget "CloudKitDemoTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA1146C02DF38D000054BA77 /* Debug */, + CA1146C12DF38D000054BA77 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; CAD001852D874E6F00FA977A /* Build configuration list for PBXNativeTarget "SyncUpTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -912,6 +1191,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + CA1146C92DF38D1D0054BA77 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + productName = SharingGRDB; + }; CA14DBC82DA884C400E36852 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; From b112c2efdf9dfe298f582bbecda9b58ea28354f6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 7 Jun 2025 13:04:33 -0700 Subject: [PATCH 115/581] Use a database pool for metadatabase. --- Sources/SharingGRDBCore/CloudKit/Metadatabase.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 502dc92c..7c4f3241 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -22,7 +22,7 @@ func defaultMetadatabase( at: .applicationSupportDirectory, withIntermediateDirectories: true ) - let metadatabase = try DatabaseQueue( + let metadatabase = try DatabasePool( path: url.path(percentEncoded: false), configuration: configuration ) From d00462206442f468d5e18da5853cde2416b09fdc Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 8 Jun 2025 10:21:13 -0700 Subject: [PATCH 116/581] Parent foreign key is defined as either the one and only non-null FK, or a null FK if it is the only FK and self-referential --- .../CloudKitDemo/CountersListFeature.swift | 92 +++++++++++++------ Examples/CloudKitDemo/Schema.swift | 9 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 + 3 files changed, 74 insertions(+), 29 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 04ebbadb..a75983ec 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -8,39 +8,17 @@ struct CountersListView: View { init(parentCounter: Counter? = nil) { self.parentCounter = parentCounter - _counters = FetchAll(Counter.where { $0.parentCounterID.is(parentCounter?.id) }) + _counters = FetchAll( + Counter + .where { $0.parentCounterID.is(parentCounter?.id) } + .order(by: \.name) + ) } var body: some View { List { ForEach(counters) { counter in - HStack { - NavigationLink { - CountersListView(parentCounter: counter) - } label: { - Text("\(counter.count)") - } - Button("-") { - withErrorReporting { - try database.write { db in - try Counter.find(counter.id).update { - $0.count -= 1 - } - .execute(db) - } - } - } - Button("+") { - withErrorReporting { - try database.write { db in - try Counter.find(counter.id).update { - $0.count += 1 - } - .execute(db) - } - } - } - } + CounterRow(counter: counter) .buttonStyle(.borderless) } .onDelete { indexSet in @@ -54,6 +32,7 @@ struct CountersListView: View { } } } + .navigationTitle(parentCounter?.name ?? "Root") .toolbar { ToolbarItem(placement: .primaryAction) { Button("Add") { @@ -68,3 +47,60 @@ struct CountersListView: View { } } } + +struct CounterRow: View { + let counter: Counter + @State var editedName = "" + @FocusState var isFocused: Bool + @Dependency(\.defaultDatabase) var database + var body: some View { + HStack { + NavigationLink { + CountersListView(parentCounter: counter) + } label: { + HStack { + TextField("Name", text: $editedName) + .focused($isFocused) + .onSubmit { saveName() } + .onChange(of: isFocused) { saveName() } + Spacer() + Text("\(counter.count)") + } + } + Button("-") { + withErrorReporting { + try database.write { db in + try Counter.find(counter.id).update { + $0.count -= 1 + } + .execute(db) + } + } + } + Button("+") { + withErrorReporting { + try database.write { db in + try Counter.find(counter.id).update { + $0.count += 1 + } + .execute(db) + } + } + } + } + .onChange(of: counter.name, initial: true) { + editedName = counter.name + } + } + + func saveName() { + withErrorReporting { + try database.write { db in + try Counter + .find(counter.id) + .update { $0.name = editedName } + .execute(db) + } + } + } +} diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index d2b7f841..f915a10c 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -6,12 +6,18 @@ struct Counter: Identifiable { let id: UUID var count = 0 var parentCounterID: Counter.ID? + var name = "" } func appDatabase() throws -> any DatabaseWriter { let path = URL.documentsDirectory.appendingPathComponent("db.sqlite").path() var configuration = Configuration() configuration.foreignKeysEnabled = false + configuration.prepareDatabase { db in + db.trace { + print($0.expandedDescription) + } + } let database = try DatabasePool(path: path, configuration: configuration) var migrator = DatabaseMigrator() @@ -23,7 +29,8 @@ func appDatabase() throws -> any DatabaseWriter { CREATE TABLE "counters" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "count" INT NOT NULL DEFAULT 0, - "parentCounterID" TEXT REFERENCES "counters"("id") ON DELETE CASCADE + "parentCounterID" TEXT REFERENCES "counters"("id") ON DELETE CASCADE, + "name" TEXT NOT NULL DEFAULT '' ) """) .execute(db) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index e7d729a3..e0aa3e53 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -301,7 +301,9 @@ extension PrimaryKeyedTable { ) throws { let foreignKey = foreignKeysByTableName[Self.tableName]?.count(where: \.notnull) == 1 + || foreignKeysByTableName[Self.tableName]?.count == 1 ? foreignKeysByTableName[Self.tableName]?.first(where: \.notnull) + ?? foreignKeysByTableName[Self.tableName]?.first : nil try Metadata.createTriggers(for: Self.self, parentForeignKey: foreignKey, db: db) From 413009057917ff87cff7869e2835d4634cb04f32 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 9 Jun 2025 15:52:50 -0700 Subject: [PATCH 117/581] wip --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 44 ++++++ .../CloudKitDemo/CountersListFeature.swift | 134 ++++++++++++------ Examples/CloudKitDemo/Schema.swift | 18 ++- Examples/Examples.xcodeproj/project.pbxproj | 8 ++ Examples/Reminders/RemindersApp.swift | 1 + Examples/Reminders/RemindersDetail.swift | 54 +++++-- Examples/Reminders/RemindersListForm.swift | 109 +++++++++++++- Examples/Reminders/Schema.swift | 25 +++- Package.resolved | 23 ++- Package.swift | 3 +- .../CloudKit/CloudKit+StructuredQueries.swift | 39 +++-- .../CloudKit/CloudKitSharing.swift | 117 ++++++++++++--- .../CloudKit/MetadataTable.swift | 19 ++- .../CloudKit/Metadatabase.swift | 10 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 31 +++- 15 files changed, 510 insertions(+), 125 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index 480f1a09..8e42f00f 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -1,9 +1,16 @@ import CloudKit import SharingGRDB import SwiftUI +#if canImport +import UIKit +#endif @main struct CloudKitDemoApp: App { +#if canImport(UIKit) + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + #endif + init() { try! prepareDependencies { $0.defaultDatabase = try appDatabase() @@ -24,3 +31,40 @@ struct CloudKitDemoApp: App { } } } + +#if canImport(UIKit) +class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + @Dependency(\.defaultSyncEngine) var syncEngine + var window: UIWindow? + func windowScene( + _ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } +} +#endif diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index a75983ec..7f8c3ed9 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -1,44 +1,95 @@ +import CloudKit +import SharingGRDB import SharingGRDB import SwiftUI +import SwiftUINavigation struct CountersListView: View { - let parentCounter: Counter? @FetchAll var counters: [Counter] + @FetchAll var sharedCounters: [CounterWithShare] @Dependency(\.defaultDatabase) var database + @State var confirmDeletion: Counter? - init(parentCounter: Counter? = nil) { - self.parentCounter = parentCounter - _counters = FetchAll( - Counter - .where { $0.parentCounterID.is(parentCounter?.id) } - .order(by: \.name) + init() { + _counters = FetchAll(Counter.nonShared) + _sharedCounters = FetchAll( + Counter.withShare + .select { + CounterWithShare.Columns( + counter: $0, + share: #sql("\($1.share)") + ) + } ) } + @Selection + struct CounterWithShare { + let counter: Counter + @Column(as: CKShare.ShareDataRepresentation.self) + let share: CKShare + } + var body: some View { List { - ForEach(counters) { counter in - CounterRow(counter: counter) - .buttonStyle(.borderless) + if !counters.isEmpty { + Section { + ForEach(counters) { counter in + CounterRow(counter: counter) + .buttonStyle(.borderless) + } + .onDelete { indexSet in + withErrorReporting { + try database.write { db in + for index in indexSet { + try Counter.find(counters[index].id).delete() + .execute(db) + } + } + } + } + } header: { + Text("Counters") + } } - .onDelete { indexSet in - withErrorReporting { - try database.write { db in - for index in indexSet { - try Counter.find(counters[index].id).delete() - .execute(db) + if !sharedCounters.isEmpty { + Section { + ForEach(sharedCounters, id: \.counter.id) { counterWithShare in + CounterRow(counter: counterWithShare.counter) + .buttonStyle(.borderless) + .swipeActions { + Button("Delete") { + confirmDeletion = counterWithShare.counter + } + .tint(.red) + } + } + } header: { + Text("Shared counters") + } + .alert(item: $confirmDeletion) { counter in + Text("Delete shared counter?") + } actions: { counter in + Button("Delete", role: .destructive) { + withErrorReporting { + try database.write { db in + try Counter.find(counter.id).delete() + .execute(db) + } } } + } message: { counter in + Text("If you delete this counter, other people will no longer have access to it.") } } } - .navigationTitle(parentCounter?.name ?? "Root") + .navigationTitle("Counters") .toolbar { ToolbarItem(placement: .primaryAction) { Button("Add") { withErrorReporting { try database.write { db in - try Counter.insert(Counter.Draft(parentCounterID: parentCounter?.id)) + try Counter.insert(Counter.Draft()) .execute(db) } } @@ -50,23 +101,13 @@ struct CountersListView: View { struct CounterRow: View { let counter: Counter - @State var editedName = "" - @FocusState var isFocused: Bool @Dependency(\.defaultDatabase) var database + @Dependency(\.defaultSyncEngine) var syncEngine + @State var sharedRecord: SharedRecord? + var body: some View { HStack { - NavigationLink { - CountersListView(parentCounter: counter) - } label: { - HStack { - TextField("Name", text: $editedName) - .focused($isFocused) - .onSubmit { saveName() } - .onChange(of: isFocused) { saveName() } - Spacer() - Text("\(counter.count)") - } - } + Text("\(counter.count)") Button("-") { withErrorReporting { try database.write { db in @@ -87,20 +128,23 @@ struct CounterRow: View { } } } + Spacer() + Button { + Task { + await withErrorReporting { + sharedRecord = try await syncEngine.createShare(record: counter) { share in + share[CKShare.SystemFieldKey.title] = "Join my counter!" + } + } + } + } label: { + Image(systemName: "square.and.arrow.up") + } } - .onChange(of: counter.name, initial: true) { - editedName = counter.name - } - } - - func saveName() { - withErrorReporting { - try database.write { db in - try Counter - .find(counter.id) - .update { $0.name = editedName } - .execute(db) - } +#if canImport(UIKit) + .sheet(item: $sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) } + #endif } } diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index f915a10c..25025d3c 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -5,8 +5,17 @@ import SharingGRDB struct Counter: Identifiable { let id: UUID var count = 0 - var parentCounterID: Counter.ID? - var name = "" + + static let withShare = Counter + .join(Metadata.all) { + #sql("\($0.id) = \($1.recordName)") + && $1.share.isNot(nil) + } + + static let nonShared = Counter + .where { counter in + !counter.id.in(#sql("\(Metadata.where { $0.share.isNot(nil) }.select(\.recordName))")) + } } func appDatabase() throws -> any DatabaseWriter { @@ -17,6 +26,7 @@ func appDatabase() throws -> any DatabaseWriter { db.trace { print($0.expandedDescription) } + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SharingGRDB.CloudKitDemo") } let database = try DatabasePool(path: path, configuration: configuration) @@ -28,9 +38,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql(""" CREATE TABLE "counters" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "count" INT NOT NULL DEFAULT 0, - "parentCounterID" TEXT REFERENCES "counters"("id") ON DELETE CASCADE, - "name" TEXT NOT NULL DEFAULT '' + "count" INT NOT NULL DEFAULT 0 ) """) .execute(db) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index fd5ff907..91f20b2e 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ CA1146CA2DF38D1D0054BA77 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA1146C92DF38D1D0054BA77 /* SharingGRDB */; }; CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; + CA42392E2DF7219E000AF560 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA42392D2DF7219E000AF560 /* SwiftUINavigation */; }; CA5E42502DE7C4D50069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E424F2DE7C4D50069E0F8 /* SwiftUINavigation */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; CAFDD64A2D5E823A00EE099E /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CAFDD6492D5E823A00EE099E /* SharingGRDB */; }; @@ -143,6 +144,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA42392E2DF7219E000AF560 /* SwiftUINavigation in Frameworks */, CA1146CA2DF38D1D0054BA77 /* SharingGRDB in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -260,6 +262,7 @@ name = CloudKitDemo; packageProductDependencies = ( CA1146C92DF38D1D0054BA77 /* SharingGRDB */, + CA42392D2DF7219E000AF560 /* SwiftUINavigation */, ); productName = CloudKitDemo; productReference = CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */; @@ -1205,6 +1208,11 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = UIKitNavigation; }; + CA42392D2DF7219E000AF560 /* SwiftUINavigation */ = { + isa = XCSwiftPackageProductDependency; + package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; + productName = SwiftUINavigation; + }; CA5E424F2DE7C4D50069E0F8 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 7d28766f..73e57644 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -16,6 +16,7 @@ struct RemindersApp: App { database: $0.defaultDatabase, tables: [ RemindersList.self, + RemindersListAsset.self, Reminder.self, Tag.self, ReminderTag.self, diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index d5429fa5..2d5b62aa 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -6,6 +6,7 @@ import SwiftUINavigation struct RemindersDetailView: View { @FetchAll private var reminderStates: [ReminderState] + @FetchOne private var coverImageData: Data? @AppStorage private var ordering: Ordering @AppStorage private var showCompleted: Bool @@ -25,19 +26,19 @@ struct RemindersDetailView: View { "show_completed_list_\(detailType.id)" ) _reminderStates = FetchAll(remindersQuery, animation: .default) + if let remindersListID = detailType.list?.id { + _coverImageData = FetchOne( + RemindersListAsset + .where { $0.remindersListID.eq(remindersListID) } + .select { #sql("\($0.coverImage)") } + ) + } } var body: some View { List { - VStack(alignment: .leading) { - GeometryReader { proxy in - Text(detailType.navigationTitle) - .font(.system(.largeTitle, design: .rounded, weight: .bold)) - .foregroundStyle(detailType.color) - .onAppear { navigationTitleHeight = proxy.size.height } - } - } - .listRowSeparator(.hidden) + header + ForEach(reminderStates) { reminderState in ReminderRow( color: detailType.color, @@ -144,6 +145,41 @@ struct RemindersDetailView: View { } } + @ViewBuilder + var header: some View { + if let coverImageData, let image = UIImage(data: coverImageData) { + ZStack(alignment: .bottomLeading) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(height: 200) + .clipped() + + GeometryReader { proxy in + Text(detailType.navigationTitle) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(detailType.color) + .padding() + .background(Color.black.opacity(0.4)) + .cornerRadius(10) + .padding() + .onAppear { navigationTitleHeight = proxy.size.height } + } + } + .listRowInsets(EdgeInsets()) + } else { + VStack(alignment: .leading) { + GeometryReader { proxy in + Text(detailType.navigationTitle) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(detailType.color) + .onAppear { navigationTitleHeight = proxy.size.height } + } + } + .listRowSeparator(.hidden) + } + } + @Dependency(\.defaultSyncEngine) var syncEngine private func shareButtonTapped(remindersList: RemindersList) { Task { diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 448491d0..8c790350 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -1,4 +1,5 @@ import IssueReporting +import PhotosUI import SharingGRDB import SwiftUI @@ -6,6 +7,9 @@ struct RemindersListForm: View { @Dependency(\.defaultDatabase) private var database @State var remindersList: RemindersList.Draft + @State var coverImageData: Data? + @State var photosPickerItem: PhotosPickerItem? + @State private var isPhotoPickerPresented = false @Environment(\.dismiss) var dismiss init(existingList: RemindersList.Draft? = nil) { @@ -27,15 +31,74 @@ struct RemindersListForm: View { .clipShape(.buttonBorder) } ColorPicker("Color", selection: $remindersList.color) + ZStack(alignment: .topTrailing) { + ZStack { + if let coverImageData, + let uiImage = UIImage(data: coverImageData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(height: 150) + .clipped() + .cornerRadius(10) + } else { + Rectangle() + .fill(Color.secondary.opacity(0.1)) + .frame(height: 150) + .cornerRadius(10) + } + + Button("Select Cover Image") { + isPhotoPickerPresented = true + } + .padding() + .background(.ultraThinMaterial) + .clipShape(.capsule) + } + + if coverImageData != nil { + Button { + coverImageData = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .background(Color.white) + .clipShape(Circle()) + } + .padding(8) + } + } + .buttonStyle(.plain) } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem { Button("Save") { - withErrorReporting { - try database.write { db in - try RemindersList.upsert(remindersList) + Task { [remindersList, coverImageData] in + await withErrorReporting { + try await database.write { db in + let remindersListID = try RemindersList + .upsert { remindersList } + .returning(\.id) + .fetchOne(db) + guard let remindersListID + else { + reportIssue("No 'remindersListID'") + return + } + let remindersListAsset = RemindersListAsset.Draft( + coverImage: coverImageData, + remindersListID: remindersListID + ) + try RemindersListAsset.insert { + remindersListAsset + } onConflict: { + $0.remindersListID + } doUpdate: { + $0.coverImage = coverImageData + } .execute(db) + } } } dismiss() @@ -47,9 +110,49 @@ struct RemindersListForm: View { } } } + .photosPicker(isPresented: $isPhotoPickerPresented, selection: $photosPickerItem) + .onChange(of: photosPickerItem) { + Task { + await withErrorReporting { + if let photosPickerItem { + coverImageData = try await photosPickerItem.loadTransferable(type: Data.self) + .flatMap { resizedAndOptimizedImageData(from: $0) } + self.photosPickerItem = nil + } + } + } + } + .task { + guard let remindersListID = remindersList.id + else { return } + await withErrorReporting { + coverImageData = try await database.read { db in + try RemindersListAsset + .where { $0.remindersListID.eq(remindersListID) } + .select(\.coverImage) + .fetchOne(db) ?? nil + } + } + } } } +func resizedAndOptimizedImageData(from data: Data, maxWidth: CGFloat = 1000) -> Data? { + guard let image = UIImage(data: data) else { return nil } + + let originalSize = image.size + let scaleFactor = min(1, maxWidth / originalSize.width) + let newSize = CGSize(width: originalSize.width * scaleFactor, + height: originalSize.height * scaleFactor) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + image.draw(in: CGRect(origin: .zero, size: newSize)) + let resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return resizedImage?.jpegData(compressionQuality: 0.8) +} + #Preview { let _ = try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index d68c8f6e..6a5711a8 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -14,6 +14,13 @@ struct RemindersList: Hashable, Identifiable { var title = "" } +@Table +struct RemindersListAsset: Hashable, Identifiable { + let id: UUID + var coverImage: Data? + let remindersListID: RemindersList.ID +} + @Table struct Reminder: Equatable, Identifiable { let id: UUID @@ -125,17 +132,27 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), "title" TEXT NOT NULL ) STRICT """ ) .execute(db) + try #sql( + """ + CREATE TABLE "remindersListAssets" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "coverImage" BLOB, + "remindersListID" TEXT NOT NULL UNIQUE REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) try #sql( """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "dueDate" TEXT, "isCompleted" INTEGER NOT NULL DEFAULT 0, "isFlagged" INTEGER NOT NULL DEFAULT 0, @@ -152,7 +169,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL COLLATE NOCASE ) STRICT """ @@ -161,7 +178,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersTags" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "reminderID" TEXT NOT NULL, "tagID" TEXT NOT NULL, diff --git a/Package.resolved b/Package.resolved index da90bdc7..d288f5f0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0a9d215bd95753f1b1f21ad3580ad7325b2a80e8876732095c77b2454586e3b9", + "originHash" : "862df5d986d736af274bff845a78db7c3dc78b921515ebd75988f91fb74bd8b1", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "732871fabfc6b38fcdff5ad2f7336327dbf78e81", - "version" : "2.4.0" + "revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9", + "version" : "2.5.2" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b", - "version" : "1.18.3" + "revision" : "37230a37e83f1b7023be08e1b1a2603fcb1567fb", + "version" : "1.18.4" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "13ce4ee7d9187a8285d9b70edc6e329239328633", - "version" : "0.4.0" + "branch" : "main", + "revision" : "a4f05166f17ed754d7a8e3c93b26f05211521e16" } }, { @@ -154,6 +154,15 @@ "version" : "600.0.1" } }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index b3b62742..6ac4247f 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,8 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.4.0"), + //.package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.4.0"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "main"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 691b923b..3ff030d6 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -2,21 +2,36 @@ import CloudKit import CustomDump import StructuredQueriesCore +//extension CKAsset { +// public struct DataRepresentation: QueryBindable, QueryRepresentable { +// public let queryOutput: CKAsset +// public var queryBinding: QueryBinding { +// fatalError() +// } +// public init(queryOutput: CKAsset) { +// self.queryOutput = queryOutput +// } +// public init(decoder: inout some QueryDecoder) throws { +// fatalError() +// } +// } +//} + extension CKRecord { - package struct DataRepresentation: QueryBindable, QueryRepresentable { - package let queryOutput: CKRecord + public struct DataRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: CKRecord - package var queryBinding: QueryBinding { + public var queryBinding: QueryBinding { let archiver = NSKeyedArchiver(requiringSecureCoding: true) queryOutput.encodeSystemFields(with: archiver) return archiver.encodedData.queryBinding } - package init(queryOutput: CKRecord) { + public init(queryOutput: CKRecord) { self.queryOutput = queryOutput } - package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { guard let data = try Data?(decoder: &decoder) else { throw QueryDecodingError.missingRequiredColumn } @@ -33,20 +48,20 @@ extension CKRecord { } extension CKShare { - package struct ShareDataRepresentation: QueryBindable, QueryRepresentable { - package let queryOutput: CKShare + public struct ShareDataRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: CKShare - package var queryBinding: QueryBinding { + public var queryBinding: QueryBinding { let archiver = NSKeyedArchiver(requiringSecureCoding: true) queryOutput.encodeSystemFields(with: archiver) return archiver.encodedData.queryBinding } - package init(queryOutput: CKShare) { + public init(queryOutput: CKShare) { self.queryOutput = queryOutput } - package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { guard let data = try Data?(decoder: &decoder) else { throw QueryDecodingError.missingRequiredColumn } @@ -60,11 +75,11 @@ extension CKShare { } extension CKRecord? { - package typealias DataRepresentation = CKRecord.DataRepresentation? + public typealias DataRepresentation = CKRecord.DataRepresentation? } extension CKShare? { - package typealias ShareDataRepresentation = CKShare.ShareDataRepresentation? + public typealias ShareDataRepresentation = CKShare.ShareDataRepresentation? } @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index b279de04..a20753a2 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -18,9 +18,9 @@ extension SyncEngine { public struct NoCKRecordFound: Error {} // TODO: beef up to take query and bundle into @Selection? -// public func records(for _: T.Type) async throws -> [CKRecord] { -// [] -// } + // public func records(for _: T.Type) async throws -> [CKRecord] { + // [] + // } public func record(for record: T) async throws -> CKRecord? { let lastKnownServerRecord = try await metadatabase.read { db in @@ -49,14 +49,13 @@ extension SyncEngine { public func share( for record: T ) async throws -> CKShare? - where T.TableColumns.PrimaryKey == UUID - { + where T.TableColumns.PrimaryKey == UUID { let primaryKey = record[keyPath: T.columns.primaryKey.keyPath] let share = try await metadatabase.read { db in try Metadata .where { $0.recordName.eq(primaryKey.uuidString.lowercased()) - && $0.recordType.eq(T.tableName) + && $0.recordType.eq(T.tableName) } .select(\.share) .fetchOne(db) ?? nil @@ -67,7 +66,7 @@ extension SyncEngine { // TODO: figure out if this share belongs to us or someone else so that we can choose between privateCloudDatabase and sharedCloudDatabase // TODO: figure out how to expose private/shared database to outside world return (try await container.privateCloudDatabase.record(for: share.recordID) as? CKShare) - ?? share + ?? share } // TODO: upsertShare / share. @@ -83,38 +82,54 @@ extension SyncEngine { } let recordName = record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() - let lastKnownServerRecord = - try await database.write { db in + let metadata = + try await metadatabase.read { db in try Metadata .find(recordID: CKRecord.ID(recordName: recordName)) - .select(\.lastKnownServerRecord) .fetchOne(db) } ?? nil - guard let lastKnownServerRecord + guard let metadata else { throw NoCKRecordFound() } + let rootRecord = + metadata.lastKnownServerRecord + ?? CKRecord( + recordType: metadata.recordType, + recordID: CKRecord.ID( + recordName: metadata.recordName, + zoneID: CKRecordZone.ID( + zoneName: metadata.zoneName, + ownerName: metadata.ownerName + ) + ) + ) + let sharedRecord: CKShare - if let existingShareRecordID = lastKnownServerRecord.share?.recordID, - let existingShare = try await container.privateCloudDatabase.record( - for: existingShareRecordID - ) as? CKShare + if let shareRecordID = rootRecord.share?.recordID, + let existingShare = try await container.privateCloudDatabase.record(for: shareRecordID) + as? CKShare { sharedRecord = existingShare } else { - sharedRecord = CKShare(rootRecord: lastKnownServerRecord) + sharedRecord = CKShare(rootRecord: rootRecord) } - // TODO: upsert "metadata" and store the sharedID and/or the full serialized CKShare? - // TODO: where we currently have purple warnings about cloudkit.share we should upsert that info into Metadata - configure(sharedRecord) + // TODO: We are getting an "client oplock error updating record" error in the logs when + // creating new shares / editing existing shares. _ = try await container.privateCloudDatabase.modifyRecords( - saving: [sharedRecord, lastKnownServerRecord], + saving: [sharedRecord, rootRecord], deleting: [] ) + try await database.write { db in + try Metadata + .find(recordID: CKRecord.ID(recordName: recordName)) + .update { $0.share = sharedRecord } + .execute(db) + } return SharedRecord(container: container, share: sharedRecord) } @@ -126,22 +141,42 @@ extension SyncEngine { } } -// TODO: Handle: SharingGRDB CloudKit Failure: No table to delete from: "cloudkit.share" // TODO: what kind of APIs do we need to expose for people to query for shared info? participants #if canImport(UIKit) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public struct CloudSharingView: UIViewControllerRepresentable { let sharedRecord: SharedRecord + let didFinish: (Result) -> Void + let didStopSharing: () -> Void public init(sharedRecord: SharedRecord) { + self.init(sharedRecord: sharedRecord, didFinish: { _ in }, didStopSharing: { }) + } + public init( + sharedRecord: SharedRecord, + didFinish: @escaping (Result) -> Void, + didStopSharing: @escaping () -> Void + ) { self.sharedRecord = sharedRecord + self.didFinish = didFinish + self.didStopSharing = didStopSharing + } + + public func makeCoordinator() -> CloudSharingDelegate { + CloudSharingDelegate( + share: sharedRecord.share, + didFinish: didFinish, + didStopSharing: didStopSharing + ) } public func makeUIViewController(context: Context) -> UICloudSharingController { - UICloudSharingController( + let controller = UICloudSharingController( share: sharedRecord.share, container: sharedRecord.container ) + controller.delegate = context.coordinator + return controller } public func updateUIViewController( @@ -150,4 +185,42 @@ extension SyncEngine { ) { } } + + public final class CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { + let share: CKShare + let didFinish: (Result) -> Void + let didStopSharing: () -> Void + init( + share: CKShare, + didFinish: @escaping (Result) -> Void, + didStopSharing: @escaping () -> Void + ) { + self.share = share + self.didFinish = didFinish + self.didStopSharing = didStopSharing + } + + public func itemThumbnailData(for csc: UICloudSharingController) -> Data? { + share[CKShare.SystemFieldKey.thumbnailImageData] as? Data + } + + public func itemTitle(for csc: UICloudSharingController) -> String? { + share[CKShare.SystemFieldKey.title] as? String + } + + public func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) { + didFinish(.success(())) + } + + public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { + didStopSharing() + } + + public func cloudSharingController( + _ csc: UICloudSharingController, + failedToSaveShareWithError error: any Error + ) { + didFinish(.failure(error)) + } + } #endif diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift index b41f9d40..ed3229a2 100644 --- a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift @@ -2,17 +2,17 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") -package struct Metadata: Hashable { - package var recordType: String - package var recordName: String - package var zoneName: String - package var ownerName: String - package var parentRecordName: String? +public struct Metadata: Hashable, Sendable { + public var recordType: String + public var recordName: String + public var zoneName: String + public var ownerName: String + public var parentRecordName: String? // @Column(as: CKRecord?.DataRepresentation.self) - package var lastKnownServerRecord: CKRecord? + public var lastKnownServerRecord: CKRecord? // @Column(as: CKShare?.ShareDataRepresentation.self) - package var share: CKShare? - package var userModificationDate: Date? + public var share: CKShare? + public var userModificationDate: Date? } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table { @@ -67,4 +67,3 @@ package struct Metadata: Hashable { self.share = share } } - diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 7c4f3241..8122d566 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -34,8 +34,7 @@ func defaultMetadatabase( #endif migrator.registerMigration("Create Metadata Tables") { db in // TODO: Should "recordName" be "collate no case"? - // TODO: should primary key be (recordType, recordName) so that we can use autoincrementing - // UUIDs in tests? + // TODO: Should we have an index of "share" so that we can efficiently find non-nil rows? try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( @@ -60,6 +59,13 @@ func defaultMetadatabase( """ ) .execute(db) + try SQLQueryExpression( + """ + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_share" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("share") + """ + ) + .execute(db) try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index e0aa3e53..77fc45cf 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -52,7 +52,7 @@ public final class SyncEngine: Sendable { }, database: database, logger: logger, - metadatabaseURL: URL.metadatabase(container: container), + metadatabaseURL: URL.metadatabase(containerIdentifier: container.containerIdentifier), tables: tables ) } @@ -301,9 +301,7 @@ extension PrimaryKeyedTable { ) throws { let foreignKey = foreignKeysByTableName[Self.tableName]?.count(where: \.notnull) == 1 - || foreignKeysByTableName[Self.tableName]?.count == 1 ? foreignKeysByTableName[Self.tableName]?.first(where: \.notnull) - ?? foreignKeysByTableName[Self.tableName]?.first : nil try Metadata.createTriggers(for: Self.self, parentForeignKey: foreignKey, db: db) @@ -577,6 +575,7 @@ extension SyncEngine: CKSyncEngineDelegate { try await self.cacheShare(share) } } else { + print(record) self.upsertFromServerRecord(record) self.refreshLastKnownServerRecord(record) } @@ -882,9 +881,9 @@ extension String { @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) extension URL { - fileprivate static func metadatabase(container: CKContainer) -> Self { + fileprivate static func metadatabase(containerIdentifier: String?) -> Self { applicationSupportDirectory.appending( - component: "\(container.containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" + component: "\(containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" ) } } @@ -918,3 +917,25 @@ struct SyncEngines { return _shared } } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Database { + public func attachMetadatabase(containerIdentifier: String) throws { + let url = URL.metadatabase(containerIdentifier: containerIdentifier) + let path = url.path(percentEncoded: false) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + _ = try DatabasePool(path: path).write { db in + try SQLQueryExpression("SELECT 1").execute(db) + } + // TODO: touch/create empty db.sqlite + try SQLQueryExpression( + """ + ATTACH DATABASE \(bind: path) AS \(quote: .sqliteDataCloudKitSchemaName) + """ + ) + .execute(self) + } +} From 34d2a0fd65d871251e3016a6ef818ea5168cdaf0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 9 Jun 2025 18:13:05 -0700 Subject: [PATCH 118/581] support for data blobs/assets and some fixes for sharing --- .../CloudKitDemo/CountersListFeature.swift | 2 +- .../xcschemes/CloudKitDemo.xcscheme | 91 +++++++++++++++++++ Examples/Reminders/RemindersDetail.swift | 2 +- Examples/Reminders/RemindersListForm.swift | 9 +- Examples/Reminders/RemindersListRow.swift | 32 +++++-- Examples/Reminders/Schema.swift | 2 +- .../CloudKit/CloudKit+Helpers.swift | 6 ++ .../CloudKit/CloudKit+StructuredQueries.swift | 4 +- .../CloudKit/CloudKitSharing.swift | 62 +------------ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 39 ++++---- 10 files changed, 155 insertions(+), 94 deletions(-) create mode 100644 Examples/Examples.xcodeproj/xcshareddata/xcschemes/CloudKitDemo.xcscheme diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 7f8c3ed9..59d0557d 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -132,7 +132,7 @@ struct CounterRow: View { Button { Task { await withErrorReporting { - sharedRecord = try await syncEngine.createShare(record: counter) { share in + sharedRecord = try await syncEngine.share(record: counter) { share in share[CKShare.SystemFieldKey.title] = "Join my counter!" } } diff --git a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CloudKitDemo.xcscheme b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CloudKitDemo.xcscheme new file mode 100644 index 00000000..46a630af --- /dev/null +++ b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CloudKitDemo.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 2d5b62aa..ea5b08e7 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -184,7 +184,7 @@ struct RemindersDetailView: View { private func shareButtonTapped(remindersList: RemindersList) { Task { await withErrorReporting { - sharedRecord = try await syncEngine.createShare(record: remindersList) { + sharedRecord = try await syncEngine.share(record: remindersList) { $0[CKShare.SystemFieldKey.title] = remindersList.title as CKRecordValue } } diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 8c790350..74c8157a 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -86,12 +86,11 @@ struct RemindersListForm: View { reportIssue("No 'remindersListID'") return } - let remindersListAsset = RemindersListAsset.Draft( - coverImage: coverImageData, - remindersListID: remindersListID - ) try RemindersListAsset.insert { - remindersListAsset + RemindersListAsset.Draft( + coverImage: coverImageData, + remindersListID: remindersListID + ) } onConflict: { $0.remindersListID } doUpdate: { diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index fe5b1684..ec9edc14 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -7,7 +7,7 @@ struct RemindersListRow: View { let remindersList: RemindersList @State var editList: RemindersList? - @State var participantNames: String? + @State var shareMessage: String? @Dependency(\.defaultDatabase) private var database @Dependency(\.defaultSyncEngine) private var syncEngine @@ -22,8 +22,8 @@ struct RemindersListRow: View { ) VStack(alignment: .leading, spacing: 4) { Text(remindersList.title) - if let participantNames { - Text("Shared with \(participantNames)") + if let shareMessage { + Text(shareMessage) .font(.footnote) .foregroundStyle(Color.secondary) } @@ -59,12 +59,28 @@ struct RemindersListRow: View { } .task { await withErrorReporting { - guard let share = try await syncEngine.share(for: remindersList) + let share = + try await database.read { db in + try Metadata + .where { #sql("\($0.recordName) = \(remindersList.id)") } + .select(\.share) + .fetchOne(db) + } ?? nil + guard let share else { return } - participantNames = share.participants - .filter { $0 != share.currentUserParticipant } - .compactMap { $0.userIdentity.nameComponents?.formatted() } - .joined(separator: ", ") + if share.owner == share.currentUserParticipant { + let participantNames = share.participants + .filter { $0 != share.currentUserParticipant } + .compactMap { $0.userIdentity.nameComponents?.formatted() } + .joined(separator: ", ") + if participantNames.count > 0 { + shareMessage = "Shared with \(participantNames)" + } else { + shareMessage = "Shared" + } + } else if let ownerName = share.owner.userIdentity.nameComponents?.formatted() { + shareMessage = "Shared from \(ownerName)" + } } } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 6a5711a8..bd472d8f 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -100,7 +100,7 @@ func appDatabase() throws -> any DatabaseWriter { configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in #if DEBUG - //db.attachMetadatabase() + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.sharing-grdb.Reminders") db.trace(options: .profile) { if context == .preview { print("\($0.expandedDescription)") diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift index b877da11..03326417 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift @@ -15,4 +15,10 @@ extension CKContainer { add(operation) } } + + func database(for recordID: CKRecord.ID) -> CKDatabase { + recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? privateCloudDatabase + : sharedCloudDatabase + } } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 3ff030d6..39dcddb9 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -92,7 +92,9 @@ extension CKRecord { let value = Value(queryOutput: row[keyPath: column.keyPath]) switch value.queryBinding { case .blob(let value): - encryptedValues[column.name] = Data(value) + let blobURL = URL.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).data") + try? Data(value).write(to: blobURL) + self[column.name] = CKAsset(fileURL: blobURL) case .double(let value): encryptedValues[column.name] = value case .date(let value): diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index a20753a2..0409dd3f 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -17,61 +17,7 @@ extension SyncEngine { public struct CantShareRecordWithParent: Error {} public struct NoCKRecordFound: Error {} - // TODO: beef up to take query and bundle into @Selection? - // public func records(for _: T.Type) async throws -> [CKRecord] { - // [] - // } - - public func record(for record: T) async throws -> CKRecord? { - let lastKnownServerRecord = try await metadatabase.read { db in - try Metadata - .where { $0.recordType.eq(T.tableName) } - .select(\.lastKnownServerRecord) - .fetchOne(db) ?? nil - } - - guard let lastKnownServerRecord - else { return nil } - // TODO: Add logic to determine privateCloudDatabase vs sharedCloudDatabase - return try await container.privateCloudDatabase.record(for: lastKnownServerRecord.recordID) - } - - public func shares(for _: T.Type) throws -> [CKShare] { - try metadatabase.read { db in - try Metadata - .where { $0.recordType.eq(T.tableName) } - .select(\.share) - .fetchAll(db) - .compactMap(\.self) - } - } - public func share( - for record: T - ) async throws -> CKShare? - where T.TableColumns.PrimaryKey == UUID { - let primaryKey = record[keyPath: T.columns.primaryKey.keyPath] - let share = try await metadatabase.read { db in - try Metadata - .where { - $0.recordName.eq(primaryKey.uuidString.lowercased()) - && $0.recordType.eq(T.tableName) - } - .select(\.share) - .fetchOne(db) ?? nil - } - guard let share - else { return nil } - // TODO: If we feel confident that our CKShares are always up to date, let's not even refresh - // TODO: figure out if this share belongs to us or someone else so that we can choose between privateCloudDatabase and sharedCloudDatabase - // TODO: figure out how to expose private/shared database to outside world - return (try await container.privateCloudDatabase.record(for: share.recordID) as? CKShare) - ?? share - } - - // TODO: upsertShare / share. - // share(record:) is very similar to share(for:) - public func createShare( record: T, configure: @Sendable (CKShare) -> Void ) async throws -> SharedRecord @@ -109,8 +55,8 @@ extension SyncEngine { let sharedRecord: CKShare if let shareRecordID = rootRecord.share?.recordID, - let existingShare = try await container.privateCloudDatabase.record(for: shareRecordID) - as? CKShare + let existingShare = try await container.database(for: rootRecord.recordID) + .record(for: shareRecordID) as? CKShare { sharedRecord = existingShare } else { @@ -141,8 +87,6 @@ extension SyncEngine { } } -// TODO: what kind of APIs do we need to expose for people to query for shared info? participants - #if canImport(UIKit) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public struct CloudSharingView: UIViewControllerRepresentable { @@ -150,7 +94,7 @@ extension SyncEngine { let didFinish: (Result) -> Void let didStopSharing: () -> Void public init(sharedRecord: SharedRecord) { - self.init(sharedRecord: sharedRecord, didFinish: { _ in }, didStopSharing: { }) + self.init(sharedRecord: sharedRecord, didFinish: { _ in }, didStopSharing: {}) } public init( sharedRecord: SharedRecord, diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 77fc45cf..dde9e6a7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -193,7 +193,9 @@ public final class SyncEngine: Sendable { await withErrorReporting(.sqliteDataCloudKitFailure) { try await metadatabase.write { db in for recordType in recordTypesToFetch { - try RecordType.upsert(RecordType.Draft(recordType)).execute(db) + try RecordType + .upsert { RecordType.Draft(recordType) } + .execute(db) } } } @@ -528,12 +530,12 @@ extension SyncEngine: CKSyncEngineDelegate { ) { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in - try StateSerialization.upsert( + try StateSerialization.upsert { StateSerialization.Draft( scope: syncEngine.database.databaseScope, data: event.stateSerialization ) - ) + } .execute(db) } } @@ -567,19 +569,14 @@ extension SyncEngine: CKSyncEngineDelegate { deletions: [(CKRecord.ID, CKRecord.RecordType)] ) async { await $isUpdatingWithServerRecord.withValue(true) { - await withTaskGroup { group in - for record in modifications { - group.addTask { - if let share = record as? CKShare { - await withErrorReporting { - try await self.cacheShare(share) - } - } else { - print(record) - self.upsertFromServerRecord(record) - self.refreshLastKnownServerRecord(record) - } + for record in modifications { + if let share = record as? CKShare { + await withErrorReporting { + try await self.cacheShare(share) } + } else { + self.upsertFromServerRecord(record) + self.refreshLastKnownServerRecord(record) } } @@ -739,7 +736,12 @@ extension SyncEngine: CKSyncEngineDelegate { query.append( columnNames .map { columnName in - encryptedValues[columnName]?.queryFragment ?? "NULL" + if let asset = record[columnName] as? CKAsset { + return (try? asset.fileURL.map { try Data(contentsOf: $0) })? + .queryFragment ?? "NULL" + } else { + return encryptedValues[columnName]?.queryFragment ?? "NULL" + } } .joined(separator: ", ") ) @@ -759,7 +761,9 @@ extension SyncEngine: CKSyncEngineDelegate { try database.write { db in try SQLQueryExpression(query).execute(db) try Metadata - .insert(Metadata(record: record)) { + .insert { + Metadata(record: record) + } onConflictDoUpdate: { $0.lastKnownServerRecord = record $0.userModificationDate = record.userModificationDate } @@ -930,7 +934,6 @@ extension Database { _ = try DatabasePool(path: path).write { db in try SQLQueryExpression("SELECT 1").execute(db) } - // TODO: touch/create empty db.sqlite try SQLQueryExpression( """ ATTACH DATABASE \(bind: path) AS \(quote: .sqliteDataCloudKitSchemaName) From 05979885275d29b9e918f168d277dc398ac242ae Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 9 Jun 2025 19:03:16 -0700 Subject: [PATCH 119/581] Force primary key of UUID for cloudkit --- Package.resolved | 6 +- Package.swift | 2 +- .../CloudKit/CloudKit+StructuredQueries.swift | 30 ++++-- .../CloudKit/CloudKitSharing.swift | 8 +- .../CloudKit/MetadataTable.swift | 100 +++++++++++++++++- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 82 ++++++++------ .../CloudKitTests/CloudKitTests.swift | 14 +-- .../CloudKitTests/MetadataTests.swift | 4 +- 8 files changed, 183 insertions(+), 63 deletions(-) diff --git a/Package.resolved b/Package.resolved index d288f5f0..0ce9b27f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "862df5d986d736af274bff845a78db7c3dc78b921515ebd75988f91fb74bd8b1", + "originHash" : "8af297bf25df3cfcb7cbd728d8e4e35a2891491c63edd266f5f9b9b091cc59b2", "pins" : [ { "identity" : "combine-schedulers", @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "main", - "revision" : "a4f05166f17ed754d7a8e3c93b26f05211521e16" + "branch" : "pkt-pat", + "revision" : "938664d23a00edee95e297ff0a596274a38557a6" } }, { diff --git a/Package.swift b/Package.swift index 6ac4247f..1ef18fd2 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.0.0"), //.package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.4.0"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "main"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "pkt-pat"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 39dcddb9..9f26e692 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -93,7 +93,9 @@ extension CKRecord { switch value.queryBinding { case .blob(let value): let blobURL = URL.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).data") - try? Data(value).write(to: blobURL) + withErrorReporting { + try Data(value).write(to: blobURL) + } self[column.name] = CKAsset(fileURL: blobURL) case .double(let value): encryptedValues[column.name] = value @@ -124,26 +126,32 @@ extension CKRecord { "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" } -extension PrimaryKeyedTable { +extension PrimaryKeyedTable where TableColumns.PrimaryKey == UUID { static func find(recordID: CKRecord.ID) -> Where { - Self.where { - SQLQueryExpression("\($0.primaryKey) = \(bind: recordID.recordName)") + let recordName = UUID(uuidString: recordID.recordName) + if recordName == nil { + reportIssue(""" + 'recordName' ("\(recordID.recordName)") must be a UUID. + """) + } + return Self.where { + $0.primaryKey.eq(recordName ?? UUID()) } } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata { - package static func find(recordID: CKRecord.ID) -> Where { - Self.where { - $0.recordName.eq(recordID.recordName) - } - } - init(record: CKRecord) { + let recordName = UUID(uuidString: record.recordID.recordName) + if recordName == nil { + reportIssue(""" + 'recordName' ("\(record.recordID.recordName)") must be a UUID. + """) + } self.init( recordType: record.recordType, - recordName: record.recordID.recordName, + recordName: recordName ?? UUID(), zoneName: record.recordID.zoneID.zoneName, ownerName: record.recordID.zoneID.ownerName, lastKnownServerRecord: record, diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 0409dd3f..fe7916fe 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -27,11 +27,11 @@ extension SyncEngine { throw CantShareRecordWithParent() } - let recordName = record[keyPath: T.columns.primaryKey.keyPath].uuidString.lowercased() + let recordName = record[keyPath: T.columns.primaryKey.keyPath] let metadata = try await metadatabase.read { db in try Metadata - .find(recordID: CKRecord.ID(recordName: recordName)) + .find(recordName) .fetchOne(db) } ?? nil @@ -45,7 +45,7 @@ extension SyncEngine { ?? CKRecord( recordType: metadata.recordType, recordID: CKRecord.ID( - recordName: metadata.recordName, + recordName: metadata.recordName.uuidString, zoneID: CKRecordZone.ID( zoneName: metadata.zoneName, ownerName: metadata.ownerName @@ -72,7 +72,7 @@ extension SyncEngine { ) try await database.write { db in try Metadata - .find(recordID: CKRecord.ID(recordName: recordName)) + .find(recordName) .update { $0.share = sharedRecord } .execute(db) } diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift index ed3229a2..6a747ced 100644 --- a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift @@ -4,7 +4,8 @@ import CloudKit // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") public struct Metadata: Hashable, Sendable { public var recordType: String - public var recordName: String + // @Column(primaryKey: true) + public var recordName: UUID public var zoneName: String public var ownerName: String public var parentRecordName: String? @@ -15,26 +16,115 @@ public struct Metadata: Hashable, Sendable { public var userModificationDate: Date? } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table { - public struct TableColumns: StructuredQueriesCore.TableDefinition { +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { public typealias QueryValue = Metadata public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) + public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + public var primaryKey: StructuredQueriesCore.TableColumn { + self.recordName + } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] } } + public struct Draft: StructuredQueriesCore.TableDraft { + public typealias PrimaryTable = Metadata + public var recordType: String + public var recordName: UUID? + public var zoneName: String + public var ownerName: String + public var parentRecordName: String? + public var lastKnownServerRecord: CKRecord? + public var share: CKShare? + public var userModificationDate: Date? + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = Metadata.Draft + public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) + public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) + public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) + public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) + public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) + public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] + } + } + public static let columns = TableColumns() + public static let tableName = Metadata.tableName + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let recordType = try decoder.decode(String.self) + self.recordName = try decoder.decode(UUID.self) + let zoneName = try decoder.decode(String.self) + let ownerName = try decoder.decode(String.self) + self.parentRecordName = try decoder.decode(String.self) + let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) + let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) + self.userModificationDate = try decoder.decode(Date.self) + guard let recordType else { + throw QueryDecodingError.missingRequiredColumn + } + guard let zoneName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let ownerName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let lastKnownServerRecord else { + throw QueryDecodingError.missingRequiredColumn + } + guard let share else { + throw QueryDecodingError.missingRequiredColumn + } + self.recordType = recordType + self.zoneName = zoneName + self.ownerName = ownerName + self.lastKnownServerRecord = lastKnownServerRecord + self.share = share + } + public init(_ other: Metadata) { + self.recordType = other.recordType + self.recordName = other.recordName + self.zoneName = other.zoneName + self.ownerName = other.ownerName + self.parentRecordName = other.parentRecordName + self.lastKnownServerRecord = other.lastKnownServerRecord + self.share = other.share + self.userModificationDate = other.userModificationDate + } + public init( + recordType: String, + recordName: UUID? = nil, + zoneName: String, + ownerName: String, + parentRecordName: String? = nil, + lastKnownServerRecord: CKRecord? = nil, + share: CKShare? = nil, + userModificationDate: Date? = nil + ) { + self.recordType = recordType + self.recordName = recordName + self.zoneName = zoneName + self.ownerName = ownerName + self.parentRecordName = parentRecordName + self.lastKnownServerRecord = lastKnownServerRecord + self.share = share + self.userModificationDate = userModificationDate + } + } public static let columns = TableColumns() public static let tableName = "sqlitedata_icloud_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) - let recordName = try decoder.decode(String.self) + let recordName = try decoder.decode(UUID.self) let zoneName = try decoder.decode(String.self) let ownerName = try decoder.decode(String.self) self.parentRecordName = try decoder.decode(String.self) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index dde9e6a7..129e0618 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -11,8 +11,8 @@ public final class SyncEngine: Sendable { let database: any DatabaseWriter let logger: Logger let metadatabase: any DatabaseWriter - let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] let foreignKeysByTableName: [String: [ForeignKey]] let syncEngines = LockIsolated(SyncEngines()) let defaultSyncEngines: @@ -24,7 +24,7 @@ public final class SyncEngine: Sendable { container: CKContainer, database: any DatabaseWriter, logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), - tables: [any PrimaryKeyedTable.Type] + tables: [any PrimaryKeyedTable.Type] ) throws { try self.init( container: container, @@ -62,7 +62,7 @@ public final class SyncEngine: Sendable { sharedSyncEngine: any SyncEngineProtocol, database: any DatabaseWriter, metadatabaseURL: URL, - tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) throws { try self.init( defaultSyncEngines: { _, _ in (privateSyncEngine, sharedSyncEngine) }, @@ -82,7 +82,7 @@ public final class SyncEngine: Sendable { database: any DatabaseWriter, logger: Logger, metadatabaseURL: URL, - tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) throws { // TODO: Explain why / link to documentation? precondition( @@ -108,11 +108,11 @@ public final class SyncEngine: Sendable { } } ) - Task { - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await setUpSyncEngine() - } - } + try setUpSyncEngine( + database: database, + metadatabase: metadatabase, + shouldFetchChanges: true + ) } var container: CKContainer { @@ -120,8 +120,22 @@ public final class SyncEngine: Sendable { } package func setUpSyncEngine() async throws { - // TODO: SHould we wrap these database calls in `{ … }()` to avoid await? - try await database.write { db in + try setUpSyncEngine( + database: database, + metadatabase: metadatabase, + shouldFetchChanges: false + ) + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await fetchChanges() + } + } + + nonisolated func setUpSyncEngine( + database: any DatabaseWriter, + metadatabase: any DatabaseWriter, + shouldFetchChanges: Bool + ) throws { + try database.write { db in let hasAttachedMetadatabase: Bool = try SQLQueryExpression( """ @@ -160,16 +174,10 @@ public final class SyncEngine: Sendable { shared: sharedSyncEngine ) } - - /* - TODO: When we detect a change in schema should save records? - TODO: Should we save records for everything in a table that is not in metadata? - */ - - let previousRecordTypes = try await metadatabase.read { db in + let previousRecordTypes = try metadatabase.read { db in try RecordType.all.fetchAll(db) } - let currentRecordTypes = try await database.read { db in + let currentRecordTypes = try database.read { db in try SQLQueryExpression( """ SELECT "name", "sql" @@ -189,9 +197,15 @@ public final class SyncEngine: Sendable { else { return true } return existingRecordType.schema != currentRecordType.schema } + + /* + TODO: When we detect a change in schema should save records? + TODO: Should we save records for everything in a table that is not in metadata? + */ + if !recordTypesToFetch.isEmpty { - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await metadatabase.write { db in + withErrorReporting(.sqliteDataCloudKitFailure) { + try metadatabase.write { db in for recordType in recordTypesToFetch { try RecordType .upsert { RecordType.Draft(recordType) } @@ -199,8 +213,12 @@ public final class SyncEngine: Sendable { } } } - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await fetchChanges() + if shouldFetchChanges { + Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await fetchChanges() + } + } } } } @@ -253,7 +271,11 @@ public final class SyncEngine: Sendable { } } } - try await setUpSyncEngine() + try setUpSyncEngine( + database: database, + metadatabase: metadatabase, + shouldFetchChanges: true + ) } func didUpdate(recordName: String, zoneName: String, ownerName: String) { @@ -442,7 +464,7 @@ extension SyncEngine: CKSyncEngineDelegate { missingTable = recordID return nil } - func open(_: T.Type) async -> CKRecord? { + func open>(_: T.Type) async -> CKRecord? { let row = withErrorReporting { try database.read { db in @@ -494,9 +516,9 @@ extension SyncEngine: CKSyncEngineDelegate { for table in tables { withErrorReporting(.sqliteDataCloudKitFailure) { let names = try database.read { db in - func open(_: T.Type) throws -> [String] { + func open>(_: T.Type) throws -> [UUID] { try T - .select { SQLQueryExpression("\($0.primaryKey)", as: String.self) } + .select(\.primaryKey) .fetchAll(db) } return try open(table) @@ -506,7 +528,7 @@ extension SyncEngine: CKSyncEngineDelegate { pendingRecordZoneChanges: names.map { .saveRecord( CKRecord.ID( - recordName: $0, + recordName: $0.uuidString, zoneID: Self.defaultZone.zoneID ) ) @@ -582,7 +604,7 @@ extension SyncEngine: CKSyncEngineDelegate { for (recordID, recordType) in deletions { if let table = tablesByName[recordType] { - func open(_: T.Type) { + func open>(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in try T.find(recordID: recordID) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index a643ad3a..5991b74c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -103,7 +103,7 @@ extension BaseCloudKitTests { let metadata = try await database.write { db in - try Metadata.find(recordID: record.recordID).fetchOne(db) + try Metadata.find(UUID(1)).fetchOne(db) } #expect(metadata != nil) } @@ -153,7 +153,7 @@ extension BaseCloudKitTests { @Test func insertUpdateDelete() throws { try database.write { db in try RemindersList - .insert(RemindersList(id: UUID(1), title: "Personal")) + .insert { RemindersList(id: UUID(1), title: "Personal") } .execute(db) } privateSyncEngine.state.assertPendingRecordZoneChanges([ @@ -196,7 +196,7 @@ extension BaseCloudKitTests { ) let userModificationDate = try #require( try await database.write { db in - try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne(db) ?? nil + try Metadata.find(UUID(1)).select(\.userModificationDate).fetchOne(db) ?? nil } ) @@ -213,7 +213,7 @@ extension BaseCloudKitTests { let metadata = try #require( try await database.write { db in - try Metadata.find(recordID: record.recordID).fetchOne(db) + try Metadata.find(UUID(1)).fetchOne(db) } ) // TODO: Control dates in SQLite in order to get consistent passing on float comparison @@ -237,7 +237,7 @@ extension BaseCloudKitTests { let userModificationDate = try #require( try await database.write { db in try Metadata - .find(recordID: record.recordID) + .find(UUID(1)) .select(\.userModificationDate) .fetchOne(db) ?? nil } @@ -256,7 +256,7 @@ extension BaseCloudKitTests { let metadata = try #require( try await database.write { db in - try Metadata.find(recordID: record.recordID).fetchOne(db) + try Metadata.find(UUID(1)).fetchOne(db) } ) #expect(metadata.userModificationDate == userModificationDate) @@ -286,7 +286,7 @@ extension BaseCloudKitTests { == 0 ) let metadata = try await database.write { db in - try Metadata.find(recordID: record.recordID).fetchOne(db) + try Metadata.find(UUID(1)).fetchOne(db) } #expect(metadata == nil) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 1643b58a..e9cf0571 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -26,7 +26,7 @@ extension BaseCloudKitTests { try database.write { db in let reminderMetadata = try #require( try Metadata - .find(recordID: CKRecord.ID(UUID(3))) + .find(UUID(3)) .fetchOne(db) ) #expect(reminderMetadata.parentRecordName == UUID(1).uuidString) @@ -40,7 +40,7 @@ extension BaseCloudKitTests { try database.write { db in let reminderMetadata = try #require( try Metadata - .find(recordID: CKRecord.ID(UUID(3))) + .find(UUID(3)) .fetchOne(db) ) #expect(reminderMetadata.parentRecordName == UUID(2).uuidString) From e100c6e2457b19da8840f22456cf82c7cda8fe50 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 9 Jun 2025 20:49:38 -0700 Subject: [PATCH 120/581] wip --- Examples/Reminders/ReminderForm.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 0675ce74..712f4f3a 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -171,16 +171,19 @@ struct ReminderFormView: View { private func saveButtonTapped() { withErrorReporting { try database.write { db in - let reminderID = try Reminder.upsert(reminder).returning(\.id).fetchOne(db)! + let reminderID = try Reminder + .upsert { reminder } + .returning(\.id) + .fetchOne(db)! try ReminderTag .where { $0.reminderID.eq(reminderID) } .delete() .execute(db) - try ReminderTag.insert( + try ReminderTag.insert { selectedTags.map { tag in ReminderTag(id: UUID(), reminderID: reminderID, tagID: tag.id) } - ) + } .execute(db) } } From 7fe431c8d9f2073943c4bbbe3dbec73774954283 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 10 Jun 2025 07:20:03 -0700 Subject: [PATCH 121/581] wip --- Examples/Reminders/RemindersListRow.swift | 47 +++++++++---------- Examples/Reminders/RemindersLists.swift | 15 ++++-- .../CloudKit/CloudKitSharing.swift | 8 +++- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 9 +++- 4 files changed, 48 insertions(+), 31 deletions(-) diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index ec9edc14..9886429d 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -5,9 +5,9 @@ import SwiftUI struct RemindersListRow: View { let remindersCount: Int let remindersList: RemindersList + let share: CKShare? @State var editList: RemindersList? - @State var shareMessage: String? @Dependency(\.defaultDatabase) private var database @Dependency(\.defaultSyncEngine) private var syncEngine @@ -57,31 +57,25 @@ struct RemindersListRow: View { } .presentationDetents([.medium]) } - .task { - await withErrorReporting { - let share = - try await database.read { db in - try Metadata - .where { #sql("\($0.recordName) = \(remindersList.id)") } - .select(\.share) - .fetchOne(db) - } ?? nil - guard let share - else { return } - if share.owner == share.currentUserParticipant { - let participantNames = share.participants - .filter { $0 != share.currentUserParticipant } - .compactMap { $0.userIdentity.nameComponents?.formatted() } - .joined(separator: ", ") - if participantNames.count > 0 { - shareMessage = "Shared with \(participantNames)" - } else { - shareMessage = "Shared" - } - } else if let ownerName = share.owner.userIdentity.nameComponents?.formatted() { - shareMessage = "Shared from \(ownerName)" - } + } + + var shareMessage: String? { + guard let share + else { return nil } + if share.owner == share.currentUserParticipant { + let participantNames = share.participants + .filter { $0 != share.currentUserParticipant } + .compactMap { $0.userIdentity.nameComponents?.formatted() } + .joined(separator: ", ") + if participantNames.count > 0 { + return "Shared with \(participantNames)" + } else { + return "Shared" } + } else if let ownerName = share.owner.userIdentity.nameComponents?.formatted() { + return "Shared from \(ownerName)" + } else { + return nil } } } @@ -94,7 +88,8 @@ struct RemindersListRow: View { remindersList: RemindersList( id: UUID(1), title: "Personal" - ) + ), + share: nil ) } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index fe81849e..cd0d5b0d 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -1,3 +1,4 @@ +import CloudKit import SharingGRDB import SwiftUI @@ -7,8 +8,13 @@ struct RemindersListsView: View { .group(by: \.id) .order(by: \.position) .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted } - .select { - ReminderListState.Columns(remindersCount: $1.id.count(), remindersList: $0) + .leftJoin(Metadata.all) { $0.id.eq($2.recordName) } + .select { remindersList, reminder, metadata in + ReminderListState.Columns( + remindersCount: reminder.id.count(), + remindersList: remindersList, + share: metadata.share + ) }, animation: .default ) @@ -49,6 +55,8 @@ struct RemindersListsView: View { var id: RemindersList.ID { remindersList.id } var remindersCount: Int var remindersList: RemindersList + @Column(as: CKShare?.ShareDataRepresentation.self) + var share: CKShare? } @Selection @@ -130,7 +138,8 @@ struct RemindersListsView: View { } label: { RemindersListRow( remindersCount: state.remindersCount, - remindersList: state.remindersList + remindersList: state.remindersList, + share: state.share ) } } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index fe7916fe..d48f8886 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -60,7 +60,13 @@ extension SyncEngine { { sharedRecord = existingShare } else { - sharedRecord = CKShare(rootRecord: rootRecord) + sharedRecord = CKShare( + rootRecord: rootRecord, + shareID: CKRecord.ID.init( + recordName: UUID().uuidString, + zoneID: rootRecord.recordID.zoneID + ) + ) } configure(sharedRecord) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 129e0618..18735b97 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -708,8 +708,15 @@ extension SyncEngine: CKSyncEngineDelegate { } private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { + // TODO: more efficient way to do this? try metadatabase.write { db in - try Metadata.find(recordID: recordID) + let metadata = try Metadata + .where { $0.share.isNot(nil) } + .fetchAll(db) + .first(where: { $0.share?.recordID == recordID }) ?? nil + guard let metadata + else { return } + try Metadata.find(metadata.recordName) .update { $0.share = nil } .execute(db) } From d8168f537dc4309e012b4a1855bd14927889a3b9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 10 Jun 2025 07:29:51 -0700 Subject: [PATCH 122/581] add precondition test --- .../CloudKitTests/SyncEngineTests.swift | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift new file mode 100644 index 00000000..f3cae217 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -0,0 +1,37 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { + #if os(macOS) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeysDisabled() throws { + let result = #expect( + processExitsWith: .failure, + observing: [\.standardErrorContent] + ) { + _ = try SyncEngine( + privateSyncEngine: MockSyncEngine(scope: .private, state: MockSyncEngineState()), + sharedSyncEngine: MockSyncEngine(scope: .shared, state: MockSyncEngineState()), + database: databaseWithForeignKeys(), + metadatabaseURL: URL.temporaryDirectory, + tables: [] + ) + } + #expect( + String(decoding: try #require(result).standardOutputContent, as: UTF8.self) + == "Foreign key support must be disabled to synchronize with CloudKit." + ) + } + #endif + } +} + +private func databaseWithForeignKeys() throws -> any DatabaseWriter { + try DatabaseQueue() +} From d723018f3538dacfa055ca883f0e4341a36e7165 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 10 Jun 2025 13:55:34 -0700 Subject: [PATCH 123/581] wip --- Examples/Reminders/RemindersDetail.swift | 48 ++++++++++++------- Examples/Reminders/Schema.swift | 2 + Package.resolved | 2 +- .../CloudKit/CloudKit+StructuredQueries.swift | 15 ------ 4 files changed, 35 insertions(+), 32 deletions(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index ea5b08e7..556a8a21 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -15,6 +15,7 @@ struct RemindersDetailView: View { @State var isNavigationTitleVisible = false @State var navigationTitleHeight: CGFloat = 36 @State var sharedRecord: SharedRecord? + @State var remindersListForm: RemindersList.Draft? @Dependency(\.defaultDatabase) private var database @@ -54,11 +55,11 @@ struct RemindersDetailView: View { move(from: indexSet, to: index) } } -// .onScrollGeometryChange(for: Bool.self) { geometry in -// geometry.contentOffset.y + geometry.contentInsets.top > navigationTitleHeight -// } action: { -// isNavigationTitleVisible = $1 -// } + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y + geometry.contentInsets.top > navigationTitleHeight + } action: { + isNavigationTitleVisible = $1 + } .listStyle(.plain) .sheet(isPresented: $isNewReminderSheetPresented) { if let remindersList = detailType.list { @@ -73,14 +74,14 @@ struct RemindersDetailView: View { try await updateQuery() } } -// .toolbar { -// ToolbarItem(placement: .principal) { -// Text(detailType.navigationTitle) -// .font(.headline) -// .opacity(isNavigationTitleVisible ? 1 : 0) -// .animation(.default.speed(2), value: isNavigationTitleVisible) -// } -// } + .toolbar { + ToolbarItem(placement: .principal) { + Text(detailType.navigationTitle) + .font(.headline) + .opacity(isNavigationTitleVisible ? 1 : 0) + .animation(.default.speed(2), value: isNavigationTitleVisible) + } + } .toolbarTitleDisplayMode(.inline) .toolbar { if detailType.is(\.list) { @@ -104,6 +105,14 @@ struct RemindersDetailView: View { ToolbarItem(placement: .primaryAction) { Menu { Group { + if case .list(let remindersList) = detailType { + Button { + remindersListForm = RemindersList.Draft(remindersList) + } label: { + Text("Show List Info") + Image(systemName: "info.circle") + } + } Menu { ForEach(Ordering.allCases, id: \.self) { ordering in Button { @@ -143,16 +152,23 @@ struct RemindersDetailView: View { } } } + .sheet(item: $remindersListForm) { remindersListDraft in + NavigationStack { + RemindersListForm(existingList: remindersListDraft) + } + .presentationDetents([.medium]) + .navigationTitle("Edit reminder") + } } @ViewBuilder var header: some View { if let coverImageData, let image = UIImage(data: coverImageData) { - ZStack(alignment: .bottomLeading) { + ZStack { Image(uiImage: image) .resizable() .scaledToFill() - .frame(height: 200) + .frame(maxHeight: 200) .clipped() GeometryReader { proxy in @@ -160,7 +176,7 @@ struct RemindersDetailView: View { .font(.system(.largeTitle, design: .rounded, weight: .bold)) .foregroundStyle(detailType.color) .padding() - .background(Color.black.opacity(0.4)) + .background(Color.black.opacity(0.6)) .cornerRadius(10) .padding() .onAppear { navigationTitleHeight = proxy.size.height } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index bd472d8f..4b60269f 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -14,6 +14,8 @@ struct RemindersList: Hashable, Identifiable { var title = "" } +extension RemindersList.Draft: Identifiable {} + @Table struct RemindersListAsset: Hashable, Identifiable { let id: UUID diff --git a/Package.resolved b/Package.resolved index 0ce9b27f..3d49b490 100644 --- a/Package.resolved +++ b/Package.resolved @@ -142,7 +142,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "pkt-pat", - "revision" : "938664d23a00edee95e297ff0a596274a38557a6" + "revision" : "d526793f30b8ecab73071af5624ea483ad7ffd26" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 9f26e692..c5fdafd1 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -2,21 +2,6 @@ import CloudKit import CustomDump import StructuredQueriesCore -//extension CKAsset { -// public struct DataRepresentation: QueryBindable, QueryRepresentable { -// public let queryOutput: CKAsset -// public var queryBinding: QueryBinding { -// fatalError() -// } -// public init(queryOutput: CKAsset) { -// self.queryOutput = queryOutput -// } -// public init(decoder: inout some QueryDecoder) throws { -// fatalError() -// } -// } -//} - extension CKRecord { public struct DataRepresentation: QueryBindable, QueryRepresentable { public let queryOutput: CKRecord From a44f8bb9ec4d44fdc944db5d6f6666ecb71001c0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 10 Jun 2025 19:18:21 -0700 Subject: [PATCH 124/581] fixes --- Examples/Examples.xcodeproj/project.pbxproj | 159 +++++++++++++++++- .../xcschemes/CaseStudies.xcscheme | 2 +- .../xcshareddata/xcschemes/Reminders.xcscheme | 13 +- .../xcshareddata/xcschemes/SyncUps.xcscheme | 2 +- Examples/Reminders/RemindersDetail.swift | 57 +++++-- Examples/Reminders/Schema.swift | 128 ++++++++------ Examples/RemindersTests/Reminders.xctestplan | 29 ++++ .../RemindersTests/RemindersListsTests.swift | 1 - 8 files changed, 314 insertions(+), 77 deletions(-) create mode 100644 Examples/RemindersTests/Reminders.xctestplan diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 91f20b2e..48f0d0b4 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA42392E2DF7219E000AF560 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA42392D2DF7219E000AF560 /* SwiftUINavigation */; }; CA5E42502DE7C4D50069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E424F2DE7C4D50069E0F8 /* SwiftUINavigation */; }; + CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99D72DF915D300934431 /* DependenciesTestSupport */; }; + CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */; }; + CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; CAFDD64A2D5E823A00EE099E /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CAFDD6492D5E823A00EE099E /* SharingGRDB */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; @@ -29,6 +32,13 @@ remoteGlobalIDString = CA11469E2DF38CFE0054BA77; remoteInfo = CloudKitDemo; }; + CA9F994E2DF9134D00934431 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CAF836902D4735620047AEB5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = CAF836D72D4735AB0047AEB5; + remoteInfo = Reminders; + }; CAD001812D874E6F00FA977A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CAF836902D4735620047AEB5 /* Project object */; @@ -49,6 +59,7 @@ CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudKitDemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CA5F37542D5AFBBC002E1A9E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + CA9F99482DF9134D00934431 /* RemindersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RemindersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836982D4735620047AEB5 /* CaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836A82D4735640047AEB5 /* CaseStudiesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CaseStudiesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -103,6 +114,11 @@ path = CloudKitDemoTests; sourceTree = ""; }; + CA9F99492DF9134D00934431 /* RemindersTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RemindersTests; + sourceTree = ""; + }; CAD0017E2D874E6F00FA977A /* SyncUpTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = SyncUpTests; @@ -156,6 +172,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CA9F99452DF9134D00934431 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */, + CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */, + CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD0017A2D874E6F00FA977A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -211,6 +237,7 @@ CAF8369A2D4735620047AEB5 /* CaseStudies */, CAF836AB2D4735640047AEB5 /* CaseStudiesTests */, CAF836D92D4735AB0047AEB5 /* Reminders */, + CA9F99492DF9134D00934431 /* RemindersTests */, DCBE89CD2D483FB90071F499 /* SyncUps */, CAD0017E2D874E6F00FA977A /* SyncUpTests */, CA1146A02DF38CFE0054BA77 /* CloudKitDemo */, @@ -230,6 +257,7 @@ CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */, CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */, CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */, + CA9F99482DF9134D00934431 /* RemindersTests.xctest */, ); name = Products; sourceTree = ""; @@ -291,6 +319,32 @@ productReference = CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + CA9F99472DF9134D00934431 /* RemindersTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA9F99502DF9134D00934431 /* Build configuration list for PBXNativeTarget "RemindersTests" */; + buildPhases = ( + CA9F99442DF9134D00934431 /* Sources */, + CA9F99452DF9134D00934431 /* Frameworks */, + CA9F99462DF9134D00934431 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CA9F994F2DF9134D00934431 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + CA9F99492DF9134D00934431 /* RemindersTests */, + ); + name = RemindersTests; + packageProductDependencies = ( + CA9F99D72DF915D300934431 /* DependenciesTestSupport */, + CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */, + CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */, + ); + productName = RemindersTests; + productReference = CA9F99482DF9134D00934431 /* RemindersTests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; CAD0017C2D874E6F00FA977A /* SyncUpTests */ = { isa = PBXNativeTarget; buildConfigurationList = CAD001852D874E6F00FA977A /* Build configuration list for PBXNativeTarget "SyncUpTests" */; @@ -421,7 +475,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1640; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 1640; TargetAttributes = { CA11469E2DF38CFE0054BA77 = { CreatedOnToolsVersion = 16.4; @@ -430,6 +484,10 @@ CreatedOnToolsVersion = 16.4; TestTargetID = CA11469E2DF38CFE0054BA77; }; + CA9F99472DF9134D00934431 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = CAF836D72D4735AB0047AEB5; + }; CAD0017C2D874E6F00FA977A = { CreatedOnToolsVersion = 16.3; TestTargetID = DCBE89CB2D483FB90071F499; @@ -462,6 +520,7 @@ DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */, DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, + CA9F99D92DF9185A00934431 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -471,6 +530,7 @@ CAF836972D4735620047AEB5 /* CaseStudies */, CAF836A72D4735640047AEB5 /* CaseStudiesTests */, CAF836D72D4735AB0047AEB5 /* Reminders */, + CA9F99472DF9134D00934431 /* RemindersTests */, DCBE89CB2D483FB90071F499 /* SyncUps */, CAD0017C2D874E6F00FA977A /* SyncUpTests */, CA11469E2DF38CFE0054BA77 /* CloudKitDemo */, @@ -494,6 +554,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CA9F99462DF9134D00934431 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD0017B2D874E6F00FA977A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -546,6 +613,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CA9F99442DF9134D00934431 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD001792D874E6F00FA977A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -589,6 +663,11 @@ target = CA11469E2DF38CFE0054BA77 /* CloudKitDemo */; targetProxy = CA1146AD2DF38D000054BA77 /* PBXContainerItemProxy */; }; + CA9F994F2DF9134D00934431 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CAF836D72D4735AB0047AEB5 /* Reminders */; + targetProxy = CA9F994E2DF9134D00934431 /* PBXContainerItemProxy */; + }; CAD001822D874E6F00FA977A /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DCBE89CB2D483FB90071F499 /* SyncUps */; @@ -610,6 +689,7 @@ CODE_SIGN_ENTITLEMENTS = CloudKitDemo/CloudKitDemo.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -649,6 +729,7 @@ CODE_SIGN_ENTITLEMENTS = CloudKitDemo/CloudKitDemo.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -686,9 +767,9 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VFRXY8HC3H; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; MACOSX_DEPLOYMENT_TARGET = 15.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemoTests; @@ -709,9 +790,9 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VFRXY8HC3H; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; MACOSX_DEPLOYMENT_TARGET = 15.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemoTests; @@ -726,6 +807,42 @@ }; name = Release; }; + CA9F99512DF9134D00934431 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.RemindersTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Reminders; + }; + name = Debug; + }; + CA9F99522DF9134D00934431 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.RemindersTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Reminders; + }; + name = Release; + }; CAD001832D874E6F00FA977A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -733,7 +850,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUpTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -751,7 +867,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUpTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -946,7 +1061,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudiesTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -963,7 +1077,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudiesTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1110,6 +1223,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CA9F99502DF9134D00934431 /* Build configuration list for PBXNativeTarget "RemindersTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA9F99512DF9134D00934431 /* Debug */, + CA9F99522DF9134D00934431 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; CAD001852D874E6F00FA977A /* Build configuration list for PBXNativeTarget "SyncUpTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1167,6 +1289,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + CA9F99D92DF9185A00934431 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.18.4; + }; + }; DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; @@ -1218,6 +1348,21 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = SwiftUINavigation; }; + CA9F99D72DF915D300934431 /* DependenciesTestSupport */ = { + isa = XCSwiftPackageProductDependency; + package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; + productName = DependenciesTestSupport; + }; + CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */ = { + isa = XCSwiftPackageProductDependency; + package = CA9F99D92DF9185A00934431 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = SnapshotTestingCustomDump; + }; + CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */ = { + isa = XCSwiftPackageProductDependency; + package = CA9F99D92DF9185A00934431 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = InlineSnapshotTesting; + }; CAD001862D874F1F00FA977A /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CaseStudies.xcscheme b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CaseStudies.xcscheme index 5c30788d..166dbd72 100644 --- a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CaseStudies.xcscheme +++ b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CaseStudies.xcscheme @@ -1,6 +1,6 @@ + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + diff --git a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme index 97c6d079..edb94fdb 100644 --- a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme +++ b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme @@ -1,6 +1,6 @@ any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() - configuration.foreignKeysEnabled = true + configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in #if DEBUG + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.sharing-grdb.Reminders") db.trace(options: .profile) { if context == .live { logger.debug("\($0.expandedDescription)") @@ -115,11 +123,17 @@ func appDatabase() throws -> any DatabaseWriter { context == .live ? URL.documentsDirectory.appending(component: "db.sqlite").path() : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - logger.info("open \(path)") + logger.debug( + """ + App database + open "\(path)" + """ + ) database = try DatabasePool(path: path, configuration: configuration) } var migrator = DatabaseMigrator() #if DEBUG + // TODO: should we warn against this for CK apps? migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create initial tables") { db in @@ -134,6 +148,16 @@ func appDatabase() throws -> any DatabaseWriter { """ ) .execute(db) + try #sql( + """ + CREATE TABLE "remindersListAssets" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "coverImage" BLOB, + "remindersListID" TEXT NOT NULL UNIQUE REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) try #sql( """ CREATE TABLE "reminders" ( @@ -178,13 +202,11 @@ func appDatabase() throws -> any DatabaseWriter { try migrator.migrate(database) - if context == .preview { - try database.write { db in + try database.write { db in + if context == .preview { try db.seedSampleData() } - } - try database.write { db in try #sql( """ CREATE TEMPORARY TRIGGER "default_position_reminders_lists" @@ -232,128 +254,128 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { func seedSampleData() throws { - let remindersListIDs = (0...2).map { _ in UUID() } - let reminderIDs = (0...10).map { _ in UUID() } - let tagIDs = (0...6).map { _ in UUID() } + let remindersListsIDs = (0...2).map { _ in UUID() } + let remindersIDs = (0...10).map { _ in UUID() } + let tagsIDs = (0...6).map { _ in UUID() } try seed { RemindersList( - id: remindersListIDs[0], + id: remindersListsIDs[0], color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), title: "Personal" ) RemindersList( - id: remindersListIDs[1], + id: remindersListsIDs[1], color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), title: "Family" ) RemindersList( - id: remindersListIDs[2], + id: remindersListsIDs[2], color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), title: "Business" ) Reminder( - id: reminderIDs[0], + id: remindersIDs[0], notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - remindersListID: remindersListIDs[0], + remindersListID: remindersListsIDs[0], title: "Groceries" ) Reminder( - id: reminderIDs[1], + id: remindersIDs[1], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isFlagged: true, - remindersListID: remindersListIDs[0], + remindersListID: remindersListsIDs[0], title: "Haircut" ) Reminder( - id: reminderIDs[2], + id: remindersIDs[2], dueDate: Date(), notes: "Ask about diet", priority: .high, - remindersListID: remindersListIDs[0], + remindersListID: remindersListsIDs[0], title: "Doctor appointment" ) Reminder( - id: reminderIDs[3], + id: remindersIDs[3], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), isCompleted: true, - remindersListID: remindersListIDs[0], + remindersListID: remindersListsIDs[0], title: "Take a walk" ) Reminder( - id: reminderIDs[4], + id: remindersIDs[4], dueDate: Date(), - remindersListID: remindersListIDs[0], + remindersListID: remindersListsIDs[0], title: "Buy concert tickets" ) Reminder( - id: reminderIDs[5], + id: remindersIDs[5], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), isFlagged: true, priority: .high, - remindersListID: remindersListIDs[1], + remindersListID: remindersListsIDs[1], title: "Pick up kids from school" ) Reminder( - id: reminderIDs[6], + id: remindersIDs[6], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .low, - remindersListID: remindersListIDs[1], + remindersListID: remindersListsIDs[1], title: "Get laundry" ) Reminder( - id: reminderIDs[7], + id: remindersIDs[7], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), isCompleted: false, priority: .high, - remindersListID: remindersListIDs[1], + remindersListID: remindersListsIDs[1], title: "Take out trash" ) Reminder( - id: reminderIDs[8], + id: remindersIDs[8], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), notes: """ Status of tax return Expenses for next year Changing payroll company """, - remindersListID: remindersListIDs[2], + remindersListID: remindersListsIDs[2], title: "Call accountant" ) Reminder( - id: reminderIDs[9], + id: remindersIDs[9], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .medium, - remindersListID: remindersListIDs[2], + remindersListID: remindersListsIDs[2], title: "Send weekly emails" ) Reminder( - id: reminderIDs[10], + id: remindersIDs[10], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), isCompleted: false, - remindersListID: remindersListIDs[2], + remindersListID: remindersListsIDs[2], title: "Prepare for WWDC" ) - Tag(id: tagIDs[0], title: "car") - Tag(id: tagIDs[1], title: "kids") - Tag(id: tagIDs[2], title: "someday") - Tag(id: tagIDs[3], title: "optional") - Tag(id: tagIDs[4], title: "social") - Tag(id: tagIDs[5], title: "night") - Tag(id: tagIDs[6], title: "adulting") - ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[2]) - ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[3]) - ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[6]) - ReminderTag.Draft(reminderID: reminderIDs[1], tagID: tagIDs[2]) - ReminderTag.Draft(reminderID: reminderIDs[1], tagID: tagIDs[3]) - ReminderTag.Draft(reminderID: reminderIDs[2], tagID: tagIDs[6]) - ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[0]) - ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[1]) - ReminderTag.Draft(reminderID: reminderIDs[4], tagID: tagIDs[4]) - ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[4]) - ReminderTag.Draft(reminderID: reminderIDs[10], tagID: tagIDs[4]) - ReminderTag.Draft(reminderID: reminderIDs[4], tagID: tagIDs[5]) + Tag(id: tagsIDs[0], title: "car") + Tag(id: tagsIDs[1], title: "kids") + Tag(id: tagsIDs[2], title: "someday") + Tag(id: tagsIDs[3], title: "optional") + Tag(id: tagsIDs[4], title: "social") + Tag(id: tagsIDs[5], title: "night") + Tag(id: tagsIDs[6], title: "adulting") + ReminderTag.Draft(reminderID: remindersIDs[0], tagID: tagsIDs[2]) + ReminderTag.Draft(reminderID: remindersIDs[0], tagID: tagsIDs[3]) + ReminderTag.Draft(reminderID: remindersIDs[0], tagID: tagsIDs[6]) + ReminderTag.Draft(reminderID: remindersIDs[1], tagID: tagsIDs[2]) + ReminderTag.Draft(reminderID: remindersIDs[1], tagID: tagsIDs[3]) + ReminderTag.Draft(reminderID: remindersIDs[2], tagID: tagsIDs[6]) + ReminderTag.Draft(reminderID: remindersIDs[3], tagID: tagsIDs[0]) + ReminderTag.Draft(reminderID: remindersIDs[3], tagID: tagsIDs[1]) + ReminderTag.Draft(reminderID: remindersIDs[4], tagID: tagsIDs[4]) + ReminderTag.Draft(reminderID: remindersIDs[3], tagID: tagsIDs[4]) + ReminderTag.Draft(reminderID: remindersIDs[10], tagID: tagsIDs[4]) + ReminderTag.Draft(reminderID: remindersIDs[4], tagID: tagsIDs[5]) } } } diff --git a/Examples/RemindersTests/Reminders.xctestplan b/Examples/RemindersTests/Reminders.xctestplan new file mode 100644 index 00000000..18ccddb6 --- /dev/null +++ b/Examples/RemindersTests/Reminders.xctestplan @@ -0,0 +1,29 @@ +{ + "configurations" : [ + { + "id" : "DD3C4DC0-99BB-4D11-A533-FA3986877845", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:Examples.xcodeproj", + "identifier" : "CAF836D72D4735AB0047AEB5", + "name" : "Reminders" + } + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:Examples.xcodeproj", + "identifier" : "CA9F99472DF9134D00934431", + "name" : "RemindersTests" + } + } + ], + "version" : 1 +} diff --git a/Examples/RemindersTests/RemindersListsTests.swift b/Examples/RemindersTests/RemindersListsTests.swift index 86dc485e..28eef9b3 100644 --- a/Examples/RemindersTests/RemindersListsTests.swift +++ b/Examples/RemindersTests/RemindersListsTests.swift @@ -1,4 +1,3 @@ -import DependenciesTestSupport import InlineSnapshotTesting import SnapshotTestingCustomDump import Testing From fdcbf3e00f82680df45d44effd3eddab20c35913 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 10 Jun 2025 20:42:52 -0700 Subject: [PATCH 125/581] Remove owner/zone name from Metadata, and instead use lastKnownServerRecord. --- Examples/CloudKitDemo/Info.plist | 4 +- Examples/CloudKitDemo/Schema.swift | 4 +- Sources/SharingGRDB/Exports.swift | 1 + .../CloudKit/CloudKit+StructuredQueries.swift | 2 - .../CloudKit/CloudKitSharing.swift | 5 +- .../SharingGRDBCore/CloudKit/Metadata.swift | 47 +------------ .../CloudKit/MetadataTable.swift | 51 ++++---------- .../CloudKit/Metadatabase.swift | 9 --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 67 +++++++++---------- 9 files changed, 55 insertions(+), 135 deletions(-) diff --git a/Examples/CloudKitDemo/Info.plist b/Examples/CloudKitDemo/Info.plist index 3d390066..9ef96ef8 100644 --- a/Examples/CloudKitDemo/Info.plist +++ b/Examples/CloudKitDemo/Info.plist @@ -2,8 +2,8 @@ - Bac - + CKSharingSupported + UIBackgroundModes remote-notification diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 25025d3c..79f9f4c9 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -14,7 +14,9 @@ struct Counter: Identifiable { static let nonShared = Counter .where { counter in - !counter.id.in(#sql("\(Metadata.where { $0.share.isNot(nil) }.select(\.recordName))")) + !counter.id.in( + #sql("\(Metadata.where { $0.share.isNot(nil) }.select(\.recordName))") + ) } } diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift index 07a5c659..103bfe18 100644 --- a/Sources/SharingGRDB/Exports.swift +++ b/Sources/SharingGRDB/Exports.swift @@ -1,2 +1,3 @@ @_exported import SharingGRDBCore @_exported import StructuredQueriesGRDB + diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index c5fdafd1..f874747c 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -137,8 +137,6 @@ extension Metadata { self.init( recordType: record.recordType, recordName: recordName ?? UUID(), - zoneName: record.recordID.zoneID.zoneName, - ownerName: record.recordID.zoneID.ownerName, lastKnownServerRecord: record, userModificationDate: record.userModificationDate ) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index d48f8886..21aefee7 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -46,10 +46,7 @@ extension SyncEngine { recordType: metadata.recordType, recordID: CKRecord.ID( recordName: metadata.recordName.uuidString, - zoneID: CKRecordZone.ID( - zoneName: metadata.zoneName, - ownerName: metadata.ownerName - ) + zoneID: Self.defaultZone.zoneID ) ) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 393b1646..f5f19b79 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -16,11 +16,7 @@ extension Metadata { WHEN NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() BEGIN SELECT - \(raw: .sqliteDataCloudKitSchemaName)_didUpdate( - "new"."recordName", - "new"."zoneName", - "new"."ownerName" - ); + \(raw: .sqliteDataCloudKitSchemaName)_didUpdate("new"."recordName"); END """ ) @@ -35,11 +31,7 @@ extension Metadata { WHEN NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() BEGIN SELECT - \(raw: .sqliteDataCloudKitSchemaName)_didUpdate( - "new"."recordName", - "new"."zoneName", - "new"."ownerName" - ); + \(raw: .sqliteDataCloudKitSchemaName)_didUpdate("new"."recordName"); END """ ) @@ -53,23 +45,13 @@ extension Metadata { WHEN NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() BEGIN SELECT - \(raw: .sqliteDataCloudKitSchemaName)_willDelete( - "old"."recordName", - "old"."zoneName", - "old"."ownerName" - ); + \(raw: .sqliteDataCloudKitSchemaName)_willDelete("old"."recordName"); END """ ) .execute(db) } - static func dropTriggers(db: Database) throws { -// try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_deletes""#).execute(db) -// try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_updates""#).execute(db) -// try SQLQueryExpression(#"DROP TRIGGER "\#(raw: String.sqliteDataCloudKitSchemaName)_metadata_inserts""#).execute(db) - } - static func createTriggers( for _: T.Type, parentForeignKey: ForeignKey?, @@ -82,24 +64,12 @@ extension Metadata { ( \(quote: Metadata.recordType.name), \(quote: Metadata.recordName.name), - \(quote: Metadata.zoneName.name), - \(quote: Metadata.ownerName.name), \(quote: Metadata.parentRecordName.name), \(quote: Metadata.userModificationDate.name) ) SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), - coalesce( - \(Metadata.zoneName), - \(raw: .sqliteDataCloudKitSchemaName)_getZoneName(), - \(quote: SyncEngine.defaultZone.zoneID.zoneName, delimiter: .text) - ), - coalesce( - \(Metadata.ownerName), - \(raw: .sqliteDataCloudKitSchemaName)_getOwnerName(), - \(quote: SyncEngine.defaultZone.zoneID.ownerName, delimiter: .text) - ), \(raw: foreignKey) AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -107,8 +77,6 @@ extension Metadata { ON CONFLICT(\(quote: Metadata.recordName.name)) DO UPDATE SET \(quote: Metadata.recordType.name) = "excluded".\(quote: Metadata.recordType.name), - \(quote: Metadata.zoneName.name) = "excluded".\(quote: Metadata.zoneName.name), - \(quote: Metadata.ownerName.name) = "excluded".\(quote: Metadata.ownerName.name), \(quote: Metadata.parentRecordName.name) = "excluded".\(quote: Metadata.parentRecordName.name), \(quote: Metadata.recordType.name) = "excluded".\(quote: Metadata.recordType.name), \(quote: Metadata.userModificationDate.name) = "excluded".\(quote: Metadata.userModificationDate.name) @@ -144,15 +112,6 @@ extension Metadata { .execute(db) } - static func dropTriggers( - for _: T.Type, - db: Database - ) throws { -// try SQLQueryExpression("DROP TRIGGER \(Self.deleteTriggerName(for: T.self))").execute(db) -// try SQLQueryExpression("DROP TRIGGER \(Self.updateTriggerName(for: T.self))").execute(db) -// try SQLQueryExpression("DROP TRIGGER \(Self.insertTriggerName(for: T.self))").execute(db) - } - private static func insertTriggerName( for _: T.Type ) -> SQLQueryExpression { diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift index 6a747ced..6bfe599a 100644 --- a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift @@ -6,23 +6,17 @@ public struct Metadata: Hashable, Sendable { public var recordType: String // @Column(primaryKey: true) public var recordName: UUID - public var zoneName: String - public var ownerName: String public var parentRecordName: String? // @Column(as: CKRecord?.DataRepresentation.self) public var lastKnownServerRecord: CKRecord? // @Column(as: CKShare?.ShareDataRepresentation.self) public var share: CKShare? public var userModificationDate: Date? -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { public typealias QueryValue = Metadata public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) - public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) @@ -31,40 +25,37 @@ public struct Metadata: Hashable, Sendable { self.recordName } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] + [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] } } + public struct Draft: StructuredQueriesCore.TableDraft { public typealias PrimaryTable = Metadata public var recordType: String public var recordName: UUID? - public var zoneName: String - public var ownerName: String public var parentRecordName: String? public var lastKnownServerRecord: CKRecord? public var share: CKShare? public var userModificationDate: Date? public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = Metadata.Draft + public typealias QueryValue = Draft public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) - public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] + [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] } } public static let columns = TableColumns() + public static let tableName = Metadata.tableName + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) self.recordName = try decoder.decode(UUID.self) - let zoneName = try decoder.decode(String.self) - let ownerName = try decoder.decode(String.self) self.parentRecordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) @@ -72,12 +63,6 @@ public struct Metadata: Hashable, Sendable { guard let recordType else { throw QueryDecodingError.missingRequiredColumn } - guard let zoneName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let ownerName else { - throw QueryDecodingError.missingRequiredColumn - } guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } @@ -85,16 +70,13 @@ public struct Metadata: Hashable, Sendable { throw QueryDecodingError.missingRequiredColumn } self.recordType = recordType - self.zoneName = zoneName - self.ownerName = ownerName self.lastKnownServerRecord = lastKnownServerRecord self.share = share } + public init(_ other: Metadata) { self.recordType = other.recordType self.recordName = other.recordName - self.zoneName = other.zoneName - self.ownerName = other.ownerName self.parentRecordName = other.parentRecordName self.lastKnownServerRecord = other.lastKnownServerRecord self.share = other.share @@ -103,8 +85,6 @@ public struct Metadata: Hashable, Sendable { public init( recordType: String, recordName: UUID? = nil, - zoneName: String, - ownerName: String, parentRecordName: String? = nil, lastKnownServerRecord: CKRecord? = nil, share: CKShare? = nil, @@ -112,21 +92,20 @@ public struct Metadata: Hashable, Sendable { ) { self.recordType = recordType self.recordName = recordName - self.zoneName = zoneName - self.ownerName = ownerName self.parentRecordName = parentRecordName self.lastKnownServerRecord = lastKnownServerRecord self.share = share self.userModificationDate = userModificationDate } } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public static let columns = TableColumns() public static let tableName = "sqlitedata_icloud_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) let recordName = try decoder.decode(UUID.self) - let zoneName = try decoder.decode(String.self) - let ownerName = try decoder.decode(String.self) self.parentRecordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) @@ -137,12 +116,6 @@ public struct Metadata: Hashable, Sendable { guard let recordName else { throw QueryDecodingError.missingRequiredColumn } - guard let zoneName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let ownerName else { - throw QueryDecodingError.missingRequiredColumn - } guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } @@ -151,9 +124,9 @@ public struct Metadata: Hashable, Sendable { } self.recordType = recordType self.recordName = recordName - self.zoneName = zoneName - self.ownerName = ownerName self.lastKnownServerRecord = lastKnownServerRecord self.share = share } } + + diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 8122d566..efe545a4 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -40,8 +40,6 @@ func defaultMetadatabase( CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( "recordType" TEXT NOT NULL, "recordName" TEXT NOT NULL PRIMARY KEY, - "zoneName" TEXT NOT NULL, - "ownerName" TEXT NOT NULL, "parentRecordName" TEXT, "lastKnownServerRecord" BLOB, "share" BLOB, @@ -52,13 +50,6 @@ func defaultMetadatabase( .execute(db) // TODO: Should we have "parentRecordName TEXT REFERENCES metadata(recordName) ON DELETE CASCADE" ? // TODO: Do we ever query for "parentRecordName"? should we add an index? - try SQLQueryExpression( - """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_zoneName_ownerName" - ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ("zoneName", "ownerName") - """ - ) - .execute(db) try SQLQueryExpression( """ CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_share" diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 18735b97..2f18850b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -232,7 +232,6 @@ public final class SyncEngine: Sendable { for table in self.tables { try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } - try Metadata.dropTriggers(db: db) db.remove(function: .willDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .getOwnerName) @@ -278,43 +277,50 @@ public final class SyncEngine: Sendable { ) } - func didUpdate(recordName: String, zoneName: String, ownerName: String) { + func didUpdate(recordName: String) { + let zoneID = zoneID(for: recordName) let syncEngine = syncEngines.withValue { - ownerName == Self.defaultZone.zoneID.ownerName ? $0.private : $0.shared + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } syncEngine?.state.add( pendingRecordZoneChanges: [ .saveRecord( CKRecord.ID( recordName: recordName, - zoneID: CKRecordZone.ID( - zoneName: zoneName, - ownerName: ownerName - ) + zoneID: zoneID ) ) ] ) } - func willDelete(recordName: String, zoneName: String, ownerName: String) { + func willDelete(recordName: String) { + let zoneID = zoneID(for: recordName) let syncEngine = syncEngines.withValue { - ownerName == Self.defaultZone.zoneID.ownerName ? $0.private : $0.shared + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } syncEngine?.state.add( pendingRecordZoneChanges: [ .deleteRecord( CKRecord.ID( recordName: recordName, - zoneID: CKRecordZone.ID( - zoneName: zoneName, - ownerName: ownerName - ) + zoneID: zoneID ) ) ] ) } + + private func zoneID(for recordName: String) -> CKRecordZone.ID { + let metadata = withErrorReporting { + try metadatabase.read { db in + try Metadata + .find(UUID(uuidString: recordName)!) + .fetchOne(db) + } + } ?? nil + return metadata?.lastKnownServerRecord?.recordID.zoneID ?? Self.defaultZone.zoneID + } } extension PrimaryKeyedTable { @@ -345,7 +351,6 @@ extension PrimaryKeyedTable { for foreignKey in foreignKeys { try foreignKey.dropTriggers(for: Self.self, db: db) } - try Metadata.dropTriggers(for: Self.self, db: db) } } @@ -697,7 +702,12 @@ extension SyncEngine: CKSyncEngineDelegate { let metadata = try await self.container.shareMetadata(for: url, shouldFetchRootRecord: true) guard let rootRecord = metadata.rootRecord - else { return } + else { + syncEngines + .withValue(\.private)?.state + .remove(pendingRecordZoneChanges: [.deleteRecord(share.recordID)]) + return + } try await database.write { db in try Metadata @@ -845,23 +855,14 @@ extension SyncEngine: CKSyncEngineDelegate { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { recordName, zoneName, ownerName in - syncEngine - .didUpdate( - recordName: recordName, - zoneName: zoneName, - ownerName: ownerName - ) + Self("didUpdate") { recordName in + syncEngine.didUpdate(recordName: recordName) } } fileprivate static func willDelete(syncEngine: SyncEngine) -> Self { - return Self("willDelete") { recordName, zoneName, ownerName in - syncEngine.willDelete( - recordName: recordName, - zoneName: zoneName, - ownerName: ownerName - ) + return Self("willDelete") { recordName in + syncEngine.willDelete(recordName: recordName) } } @@ -886,17 +887,15 @@ extension DatabaseFunction { private convenience init( _ name: String, - function: @escaping @Sendable (String, String, String) -> Void + function: @escaping @Sendable (String) -> Void ) { - self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 3) { arguments in + self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 1) { arguments in guard - let recordName = String.fromDatabaseValue(arguments[0]), - let zoneName = String.fromDatabaseValue(arguments[1]), - let ownerName = String.fromDatabaseValue(arguments[2]) + let recordName = String.fromDatabaseValue(arguments[0]) else { return nil } - function(recordName, zoneName, ownerName) + function(recordName) return nil } } From d46323882fa856b7adc61bfa7e108a68fb8acb78 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 10 Jun 2025 20:47:46 -0700 Subject: [PATCH 126/581] update tests --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 215 ++++++++---------- .../CloudKitTests/CloudKitTests.swift | 8 +- .../CloudKitTests/SharingTests.swift | 1 + .../CloudKitTests/TriggerTests.swift | 204 +---------------- .../Internal/CloudKitTestHelpers.swift | 1 + 5 files changed, 106 insertions(+), 323 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 2f18850b..954f4492 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -155,8 +155,6 @@ public final class SyncEngine: Sendable { .execute(db) } db.add(function: .isUpdatingWithServerRecord) - db.add(function: .getZoneName) - db.add(function: .getOwnerName) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .willDelete(syncEngine: self)) @@ -234,8 +232,6 @@ public final class SyncEngine: Sendable { } db.remove(function: .willDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) - db.remove(function: .getOwnerName) - db.remove(function: .getZoneName) db.remove(function: .isUpdatingWithServerRecord) } try await metadatabase.write { db in @@ -312,13 +308,14 @@ public final class SyncEngine: Sendable { } private func zoneID(for recordName: String) -> CKRecordZone.ID { - let metadata = withErrorReporting { - try metadatabase.read { db in - try Metadata - .find(UUID(uuidString: recordName)!) - .fetchOne(db) - } - } ?? nil + let metadata = + withErrorReporting { + try metadatabase.read { db in + try Metadata + .find(UUID(uuidString: recordName)!) + .fetchOne(db) + } + } ?? nil return metadata?.lastKnownServerRecord?.recordID.zoneID ?? Self.defaultZone.zoneID } } @@ -702,12 +699,7 @@ extension SyncEngine: CKSyncEngineDelegate { let metadata = try await self.container.shareMetadata(for: url, shouldFetchRootRecord: true) guard let rootRecord = metadata.rootRecord - else { - syncEngines - .withValue(\.private)?.state - .remove(pendingRecordZoneChanges: [.deleteRecord(share.recordID)]) - return - } + else { return } try await database.write { db in try Metadata @@ -720,7 +712,8 @@ extension SyncEngine: CKSyncEngineDelegate { private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { // TODO: more efficient way to do this? try metadatabase.write { db in - let metadata = try Metadata + let metadata = + try Metadata .where { $0.share.isNot(nil) } .fetchAll(db) .first(where: { $0.share?.recordID == recordID }) ?? nil @@ -734,110 +727,106 @@ extension SyncEngine: CKSyncEngineDelegate { private func upsertFromServerRecord(_ record: CKRecord) { $isUpdatingWithServerRecord.withValue(true) { - $currentZoneID.withValue(record.recordID.zoneID) { - withErrorReporting(.sqliteDataCloudKitFailure) { - let userModificationDate = - try metadatabase.read { db in - try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne( - db - ) - } - ?? nil - guard let table = tablesByName[record.recordType] - else { - reportIssue( - .sqliteDataCloudKitFailure.appending( - """ - : No table to merge from: "\(record.recordType)" - """ - ) + withErrorReporting(.sqliteDataCloudKitFailure) { + let userModificationDate = + try metadatabase.read { db in + try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne( + db ) - return } - guard - let userModificationDate, - userModificationDate > record.userModificationDate ?? .distantPast - else { - let columnNames = try database.read { db in - try SQLQueryExpression( - """ - SELECT "name" - FROM pragma_table_info(\(bind: table.tableName)) - """, - as: String.self - ) - .fetchAll(db) - } - var query: QueryFragment = "INSERT INTO \(table) (" - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) - query.append(") VALUES (") - let encryptedValues = record.encryptedValues - query.append( - columnNames - .map { columnName in - if let asset = record[columnName] as? CKAsset { - return (try? asset.fileURL.map { try Data(contentsOf: $0) })? - .queryFragment ?? "NULL" - } else { - return encryptedValues[columnName]?.queryFragment ?? "NULL" - } - } - .joined(separator: ", ") + ?? nil + guard let table = tablesByName[record.recordType] + else { + reportIssue( + .sqliteDataCloudKitFailure.appending( + """ + : No table to merge from: "\(record.recordType)" + """ ) - func open(_: T.Type) { - query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET") - } - open(table) - query.append( - columnNames - .map { - """ - \(quote: $0) = "excluded".\(quote: $0) - """ - } - .joined(separator: ",") + ) + return + } + guard + let userModificationDate, + userModificationDate > record.userModificationDate ?? .distantPast + else { + let columnNames = try database.read { db in + try SQLQueryExpression( + """ + SELECT "name" + FROM pragma_table_info(\(bind: table.tableName)) + """, + as: String.self ) - try database.write { db in - try SQLQueryExpression(query).execute(db) - try Metadata - .insert { - Metadata(record: record) - } onConflictDoUpdate: { - $0.lastKnownServerRecord = record - $0.userModificationDate = record.userModificationDate + .fetchAll(db) + } + var query: QueryFragment = "INSERT INTO \(table) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + let encryptedValues = record.encryptedValues + query.append( + columnNames + .map { columnName in + if let asset = record[columnName] as? CKAsset { + return (try? asset.fileURL.map { try Data(contentsOf: $0) })? + .queryFragment ?? "NULL" + } else { + return encryptedValues[columnName]?.queryFragment ?? "NULL" } - .execute(db) - } - return + } + .joined(separator: ", ") + ) + func open(_: T.Type) { + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET") + } + open(table) + query.append( + columnNames + .map { + """ + \(quote: $0) = "excluded".\(quote: $0) + """ + } + .joined(separator: ",") + ) + try database.write { db in + try SQLQueryExpression(query).execute(db) + try Metadata + .insert { + Metadata(record: record) + } onConflictDoUpdate: { + $0.lastKnownServerRecord = record + $0.userModificationDate = record.userModificationDate + } + .execute(db) } + return } } } } private func refreshLastKnownServerRecord(_ record: CKRecord) { - $currentZoneID.withValue(record.recordID.zoneID) { - $isUpdatingWithServerRecord.withValue(true) { - let metadata = metadataFor(recordID: record.recordID) - - func updateLastKnownServerRecord() { - withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in - try Metadata - .find(recordID: record.recordID) - .update { $0.lastKnownServerRecord = record } - .execute(db) - } + $isUpdatingWithServerRecord.withValue(true) { + let metadata = metadataFor(recordID: record.recordID) + + func updateLastKnownServerRecord() { + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + try Metadata + .find(recordID: record.recordID) + .update { $0.lastKnownServerRecord = record } + .execute(db) } } - - if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { - if let recordDate = record.modificationDate, lastKnownDate < recordDate { - updateLastKnownServerRecord() - } - } else { + } + + if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { + if let recordDate = record.modificationDate, lastKnownDate < recordDate { updateLastKnownServerRecord() } + } else { + updateLastKnownServerRecord() } } } @@ -873,18 +862,6 @@ extension DatabaseFunction { } } - fileprivate static var getZoneName: Self { - Self(.sqliteDataCloudKitSchemaName + "_" + "getZoneName", argumentCount: 0) { _ in - SharingGRDBCore.currentZoneID?.zoneName - } - } - - fileprivate static var getOwnerName: Self { - Self(.sqliteDataCloudKitSchemaName + "_" + "getOwnerName", argumentCount: 0) { _ in - SharingGRDBCore.currentZoneID?.ownerName - } - } - private convenience init( _ name: String, function: @escaping @Sendable (String) -> Void @@ -903,8 +880,6 @@ extension DatabaseFunction { // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates @TaskLocal private var isUpdatingWithServerRecord = false -@available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9, *) -@TaskLocal private var currentZoneID: CKRecordZone.ID? extension String { package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 5991b74c..a4ed55a7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -124,11 +124,9 @@ extension BaseCloudKitTests { ) { """ [ - [0]: "sqlitedata_icloud_getzonename", - [1]: "sqlitedata_icloud_didupdate", - [2]: "sqlitedata_icloud_getownername", - [3]: "sqlitedata_icloud_willdelete", - [4]: "sqlitedata_icloud_isupdatingwithserverrecord" + [0]: "sqlitedata_icloud_didupdate", + [1]: "sqlitedata_icloud_willdelete", + [2]: "sqlitedata_icloud_isupdatingwithserverrecord" ] """ } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 57e82492..289775d9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -8,6 +8,7 @@ import Testing extension BaseCloudKitTests { final class SharingTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func basics() { } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 8e8bc77c..7f73c0fa 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -22,11 +22,7 @@ extension BaseCloudKitTests { WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT - sqlitedata_icloud_didUpdate( - "new"."recordName", - "new"."zoneName", - "new"."ownerName" - ); + sqlitedata_icloud_didUpdate("new"."recordName"); END """, [1]: """ @@ -36,11 +32,7 @@ extension BaseCloudKitTests { WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT - sqlitedata_icloud_didUpdate( - "new"."recordName", - "new"."zoneName", - "new"."ownerName" - ); + sqlitedata_icloud_didUpdate("new"."recordName"); END """, [2]: """ @@ -50,11 +42,7 @@ extension BaseCloudKitTests { WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT - sqlitedata_icloud_willDelete( - "old"."recordName", - "old"."zoneName", - "old"."ownerName" - ); + sqlitedata_icloud_willDelete("old"."recordName"); END """, [3]: """ @@ -64,24 +52,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'reminders', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), "new"."remindersListID" AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -89,8 +65,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -103,24 +77,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'reminders', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), "new"."remindersListID" AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -128,8 +90,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -202,24 +162,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'remindersLists', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -227,8 +175,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -241,24 +187,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'remindersLists', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -266,8 +200,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -287,24 +219,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'users', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -312,8 +232,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -326,24 +244,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'users', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -351,8 +257,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -401,11 +305,7 @@ extension BaseCloudKitTests { WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT - sqlitedata_icloud_didUpdate( - "new"."recordName", - "new"."zoneName", - "new"."ownerName" - ); + sqlitedata_icloud_didUpdate("new"."recordName"); END """, [1]: """ @@ -415,11 +315,7 @@ extension BaseCloudKitTests { WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT - sqlitedata_icloud_didUpdate( - "new"."recordName", - "new"."zoneName", - "new"."ownerName" - ); + sqlitedata_icloud_didUpdate("new"."recordName"); END """, [2]: """ @@ -429,11 +325,7 @@ extension BaseCloudKitTests { WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT - sqlitedata_icloud_willDelete( - "old"."recordName", - "old"."zoneName", - "old"."ownerName" - ); + sqlitedata_icloud_willDelete("old"."recordName"); END """, [3]: """ @@ -443,24 +335,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'reminders', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), "new"."remindersListID" AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -468,8 +348,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -482,24 +360,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'reminders', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), "new"."remindersListID" AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -507,8 +373,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -581,24 +445,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'remindersLists', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -606,8 +458,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -620,24 +470,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'remindersLists', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -645,8 +483,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -666,24 +502,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'users', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -691,8 +515,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; @@ -705,24 +527,12 @@ extension BaseCloudKitTests { ( "recordType", "recordName", - "zoneName", - "ownerName", "parentRecordName", "userModificationDate" ) SELECT 'users', "new"."id", - coalesce( - "sqlitedata_icloud_metadata"."zoneName", - sqlitedata_icloud_getZoneName(), - 'co.pointfree.SQLiteData.defaultZone' - ), - coalesce( - "sqlitedata_icloud_metadata"."ownerName", - sqlitedata_icloud_getOwnerName(), - '__defaultOwner__' - ), NULL AS "foreignKey", datetime('subsec') FROM (SELECT 1) @@ -730,8 +540,6 @@ extension BaseCloudKitTests { ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", - "zoneName" = "excluded"."zoneName", - "ownerName" = "excluded"."ownerName", "parentRecordName" = "excluded"."parentRecordName", "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index d6c13d5a..96c4f3ed 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -4,6 +4,7 @@ import CustomDump import SharingGRDBCore extension CKRecord.ID { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) convenience init(_ id: UUID) { self.init( recordName: id.uuidString.lowercased(), From a4572394e7b93ce379b6e8fd299a0c2aaf23f5f9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 11 Jun 2025 10:10:34 -0700 Subject: [PATCH 127/581] clean up --- Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 21aefee7..0723875c 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -42,6 +42,10 @@ extension SyncEngine { let rootRecord = metadata.lastKnownServerRecord + // 1) create record + // 2) (before sync) you share + // 3) create a CKRecord down below + // 4) a moment later, sync engine creates a record ?? CKRecord( recordType: metadata.recordType, recordID: CKRecord.ID( @@ -59,7 +63,7 @@ extension SyncEngine { } else { sharedRecord = CKShare( rootRecord: rootRecord, - shareID: CKRecord.ID.init( + shareID: CKRecord.ID( recordName: UUID().uuidString, zoneID: rootRecord.recordID.zoneID ) From d78b222b52a1d10681707bdda2b903f718a3be29 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 11 Jun 2025 10:44:48 -0700 Subject: [PATCH 128/581] more work on clean up sharing --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 954f4492..282ccefa 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -602,6 +602,16 @@ extension SyncEngine: CKSyncEngineDelegate { self.upsertFromServerRecord(record) self.refreshLastKnownServerRecord(record) } + if + let shareReference = record.share, + let shareRecord = try? await container.database(for: shareReference.recordID) + .record(for: shareReference.recordID), + let share = shareRecord as? CKShare + { + await withErrorReporting { + try await cacheShare(share) + } + } } for (recordID, recordType) in deletions { @@ -701,6 +711,17 @@ extension SyncEngine: CKSyncEngineDelegate { guard let rootRecord = metadata.rootRecord else { return } + guard share.publicPermission != .none + else { + try await database.write { db in + try Metadata + .find(recordID: rootRecord.recordID) + .update { $0.share = nil } + .execute(db) + } + return + } + try await database.write { db in try Metadata .find(recordID: rootRecord.recordID) From fa6f707f99ff0fd466fd7fba2636060560c4b7c4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 11 Jun 2025 11:40:13 -0700 Subject: [PATCH 129/581] sharing fixes --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 44 ++++++------------- Package.resolved | 2 +- .../CloudKit/CloudKitSharing.swift | 4 ++ .../CloudKit/Metadatabase.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 24 ++++------ 5 files changed, 27 insertions(+), 49 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index 8e42f00f..e18656f2 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -10,19 +10,6 @@ struct CloudKitDemoApp: App { #if canImport(UIKit) @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate #endif - - init() { - try! prepareDependencies { - $0.defaultDatabase = try appDatabase() - $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.SharingGRDB.CloudKitDemo" - ), - database: $0.defaultDatabase, - tables: [Counter.self] - ) - } - } var body: some Scene { WindowGroup { NavigationStack { @@ -34,32 +21,27 @@ struct CloudKitDemoApp: App { #if canImport(UIKit) class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { + @Dependency(\.defaultSyncEngine) var syncEngine + func application( _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil ) -> Bool { + try! prepareDependencies { + $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + container: CKContainer( + identifier: "iCloud.co.pointfree.SharingGRDB.CloudKitDemo" + ), + database: $0.defaultDatabase, + tables: [Counter.self] + ) + } return true } func application( _ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions - ) -> UISceneConfiguration { - let configuration = UISceneConfiguration( - name: "Default Configuration", - sessionRole: connectingSceneSession.role - ) - configuration.delegateClass = SceneDelegate.self - return configuration - } -} - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - @Dependency(\.defaultSyncEngine) var syncEngine - var window: UIWindow? - func windowScene( - _ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata ) { Task { diff --git a/Package.resolved b/Package.resolved index 7a39bfa0..8cfb7c55 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f5261f0540baa2624ed52becc0bb4246ad0ee522376f5a8c691f1857097815d9", + "originHash" : "28f3f4908b86340cd5b87bd5b7a5f67829d8e28d9117c135ff3088826aca6ae8", "pins" : [ { "identity" : "combine-schedulers", diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 0723875c..0baf2d96 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -1,4 +1,5 @@ import CloudKit +import Dependencies import SwiftUI #if canImport(UIKit) @@ -137,6 +138,7 @@ extension SyncEngine { } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final class CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { let share: CKShare let didFinish: (Result) -> Void @@ -164,6 +166,8 @@ extension SyncEngine { } public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { + @Dependency(\.defaultSyncEngine) var syncEngine + // TODO: eagerly clear out share data didStopSharing() } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index efe545a4..2664715a 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -5,7 +5,7 @@ import os func defaultMetadatabase( logger: Logger, url: URL -) throws -> any DatabaseWriter { +) throws -> any DatabaseReader { var configuration = Configuration() configuration.prepareDatabase { [logger] db in db.trace { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 282ccefa..f985cea7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -10,7 +10,7 @@ public final class SyncEngine: Sendable { let database: any DatabaseWriter let logger: Logger - let metadatabase: any DatabaseWriter + let metadatabase: any DatabaseReader let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] let foreignKeysByTableName: [String: [ForeignKey]] @@ -132,7 +132,7 @@ public final class SyncEngine: Sendable { nonisolated func setUpSyncEngine( database: any DatabaseWriter, - metadatabase: any DatabaseWriter, + metadatabase: any DatabaseReader, shouldFetchChanges: Bool ) throws { try database.write { db in @@ -203,7 +203,7 @@ public final class SyncEngine: Sendable { if !recordTypesToFetch.isEmpty { withErrorReporting(.sqliteDataCloudKitFailure) { - try metadatabase.write { db in + try database.write { db in for recordType in recordTypesToFetch { try RecordType .upsert { RecordType.Draft(recordType) } @@ -234,7 +234,7 @@ public final class SyncEngine: Sendable { db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .isUpdatingWithServerRecord) } - try await metadatabase.write { db in + try await database.write { db in // TODO: Do an `.erase()` + re-migrate try Metadata.delete().execute(db) try RecordType.delete().execute(db) @@ -711,17 +711,6 @@ extension SyncEngine: CKSyncEngineDelegate { guard let rootRecord = metadata.rootRecord else { return } - guard share.publicPermission != .none - else { - try await database.write { db in - try Metadata - .find(recordID: rootRecord.recordID) - .update { $0.share = nil } - .execute(db) - } - return - } - try await database.write { db in try Metadata .find(recordID: rootRecord.recordID) @@ -732,7 +721,7 @@ extension SyncEngine: CKSyncEngineDelegate { private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { // TODO: more efficient way to do this? - try metadatabase.write { db in + try database.write { db in let metadata = try Metadata .where { $0.share.isNot(nil) } @@ -792,6 +781,9 @@ extension SyncEngine: CKSyncEngineDelegate { return (try? asset.fileURL.map { try Data(contentsOf: $0) })? .queryFragment ?? "NULL" } else { + if encryptedValues[columnName] == nil { + print("!!!") + } return encryptedValues[columnName]?.queryFragment ?? "NULL" } } From 0fa0a1b2521b524374b6ae64b4f9b298205dac39 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 12 Jun 2025 12:32:02 -0700 Subject: [PATCH 130/581] wip --- Package.resolved | 20 +------- .../CloudKitTests/CloudKitTests.swift | 2 + .../CloudKitTests/SyncEngineTests.swift | 46 +++++++++---------- 3 files changed, 26 insertions(+), 42 deletions(-) diff --git a/Package.resolved b/Package.resolved index 8cfb7c55..77ea785b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "28f3f4908b86340cd5b87bd5b7a5f67829d8e28d9117c135ff3088826aca6ae8", + "originHash" : "b549b3efb362489869442e9080dac8e8147d3496c09a3d8833133d27e4c2669c", "pins" : [ { "identity" : "combine-schedulers", @@ -19,15 +19,6 @@ "version" : "7.4.1" } }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", - "version" : "1.7.0" - } - }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -100,15 +91,6 @@ "version" : "1.1.1" } }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", - "version" : "2.3.0" - } - }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index a4ed55a7..bc40c705 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -292,3 +292,5 @@ extension BaseCloudKitTests { // TODO: Test what happens when we delete locally and then an edit comes in from the server } + + diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index f3cae217..96946f16 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -7,29 +7,29 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { - final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { - #if os(macOS) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func foreignKeysDisabled() throws { - let result = #expect( - processExitsWith: .failure, - observing: [\.standardErrorContent] - ) { - _ = try SyncEngine( - privateSyncEngine: MockSyncEngine(scope: .private, state: MockSyncEngineState()), - sharedSyncEngine: MockSyncEngine(scope: .shared, state: MockSyncEngineState()), - database: databaseWithForeignKeys(), - metadatabaseURL: URL.temporaryDirectory, - tables: [] - ) - } - #expect( - String(decoding: try #require(result).standardOutputContent, as: UTF8.self) - == "Foreign key support must be disabled to synchronize with CloudKit." - ) - } - #endif - } +// final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { +// #if os(macOS) +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func foreignKeysDisabled() throws { +// let result = #expect( +// processExitsWith: .failure, +// observing: [\.standardErrorContent] +// ) { +// _ = try SyncEngine( +// privateSyncEngine: MockSyncEngine(scope: .private, state: MockSyncEngineState()), +// sharedSyncEngine: MockSyncEngine(scope: .shared, state: MockSyncEngineState()), +// database: databaseWithForeignKeys(), +// metadatabaseURL: URL.temporaryDirectory, +// tables: [] +// ) +// } +// #expect( +// String(decoding: try #require(result).standardOutputContent, as: UTF8.self) +// == "Foreign key support must be disabled to synchronize with CloudKit." +// ) +// } +// #endif +// } } private func databaseWithForeignKeys() throws -> any DatabaseWriter { From e56f203ac11c6c9be7f99ded682d8eecd99bf064 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 12 Jun 2025 15:25:43 -0500 Subject: [PATCH 131/581] wip --- Examples/Reminders/RemindersLists.swift | 1 + Examples/Reminders/Schema.swift | 53 +++++++++---------------- Package.resolved | 20 +++++++++- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index a0dad486..9e10d644 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -248,6 +248,7 @@ struct RemindersListsView: View { share: state.share ) } + .buttonStyle(.borderless) .foregroundStyle(.primary) } .onMove(perform: model.move(from:to:)) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 282a96df..6ae6385f 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -9,7 +9,7 @@ import SwiftUI struct RemindersList: Hashable, Identifiable { let id: UUID @Column(as: Color.HexRepresentation.self) - var color = Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255) + var color: Color = .blue var position = 0 var title = "" } @@ -207,41 +207,26 @@ func appDatabase() throws -> any DatabaseWriter { try db.seedSampleData() } - try #sql( - """ - CREATE TEMPORARY TRIGGER "default_position_reminders_lists" - AFTER INSERT ON "remindersLists" - FOR EACH ROW BEGIN - UPDATE "remindersLists" - SET "position" = (SELECT max("position") + 1 FROM "remindersLists") - WHERE "id" = NEW."id"; - END - """ - ) + try RemindersList.createTemporaryTrigger(after: .insert { new in + RemindersList + .update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1} } + .where { $0.id.eq(new.id) } + }) .execute(db) - try #sql( - """ - CREATE TEMPORARY TRIGGER "default_position_reminders" - AFTER INSERT ON "reminders" - FOR EACH ROW BEGIN - UPDATE "reminders" - SET "position" = (SELECT max("position") + 1 FROM "reminders") - WHERE "id" = NEW."id"; - END - """ - ) + try Reminder.createTemporaryTrigger(after: .insert { new in + Reminder + .update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1} } + .where { $0.id.eq(new.id) } + }) .execute(db) - try #sql( - """ - CREATE TEMPORARY TRIGGER "non_empty_reminders_lists" - AFTER DELETE ON "remindersLists" - FOR EACH ROW BEGIN - INSERT INTO "remindersLists" - ("title", "color") - SELECT 'Personal', \(raw: 0x4a99ef) - WHERE (SELECT count(*) FROM "remindersLists") = 0; - END - """ + try RemindersList.createTemporaryTrigger( + after: .delete { _ in + RemindersList.insert { + RemindersList.Draft(color: .blue, title: "Personal") + } + } when: { _ in + RemindersList.count().eq(0) + } ) .execute(db) } diff --git a/Package.resolved b/Package.resolved index 77ea785b..b413e929 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b549b3efb362489869442e9080dac8e8147d3496c09a3d8833133d27e4c2669c", + "originHash" : "28f3f4908b86340cd5b87bd5b7a5f67829d8e28d9117c135ff3088826aca6ae8", "pins" : [ { "identity" : "combine-schedulers", @@ -19,6 +19,15 @@ "version" : "7.4.1" } }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", + "version" : "1.7.0" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -91,6 +100,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21", + "version" : "2.3.1" + } + }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", From 40eb67b52293a7648f25eaa5bf134518526b92ca Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 12 Jun 2025 15:41:50 -0500 Subject: [PATCH 132/581] wip --- .../SharingGRDBCore/CloudKit/Metadata.swift | 82 +++++++++---------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index f5f19b79..ba4c028e 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -7,52 +7,44 @@ extension Metadata { tables: [any PrimaryKeyedTable.Type], db: Database ) throws { - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_metadata_inserts" - AFTER INSERT ON \(Metadata.self) - FOR EACH ROW - WHEN NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() - BEGIN - SELECT - \(raw: .sqliteDataCloudKitSchemaName)_didUpdate("new"."recordName"); - END - """ + try createTemporaryTrigger( + ifNotExists: true, + after: .insert { + SQLQueryExpression( + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\($0.recordName))" + ) + } when: { _ in + SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } ) .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_metadata_updates" - AFTER UPDATE ON \(Metadata.self) - FOR EACH ROW - WHEN NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() - BEGIN - SELECT - \(raw: .sqliteDataCloudKitSchemaName)_didUpdate("new"."recordName"); - END - """ + try createTemporaryTrigger( + ifNotExists: true, + after: .update { _, new in + SQLQueryExpression( + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(new.recordName))" + ) + } when: { _, _ in + SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } ) .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_metadata_deletes" - BEFORE DELETE ON \(Metadata.self) - FOR EACH ROW - WHEN NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord() - BEGIN - SELECT - \(raw: .sqliteDataCloudKitSchemaName)_willDelete("old"."recordName"); - END - """ + + try createTemporaryTrigger( + ifNotExists: true, + after: .delete { + SQLQueryExpression( + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_willUpdate(\($0.recordName))" + ) + } when: { _ in + SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } ) .execute(db) } - static func createTriggers( + static func createTriggers>( for _: T.Type, parentForeignKey: ForeignKey?, db: Database @@ -100,14 +92,14 @@ extension Metadata { """ ) .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS \(Self.deleteTriggerName(for: T.self)) - AFTER DELETE ON \(T.self) FOR EACH ROW BEGIN - DELETE FROM \(Metadata.self) - WHERE "recordName" = "old".\(quote: T.columns.primaryKey.name); - END - """ + + try T.createTemporaryTrigger( + ifNotExists: true, + after: .delete { old in + Metadata + .where { $0.recordName.eq(old.primaryKey) } + .delete() + } ) .execute(db) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index f985cea7..25778bda 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -320,7 +320,7 @@ public final class SyncEngine: Sendable { } } -extension PrimaryKeyedTable { +extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) fileprivate static func createTriggers( foreignKeysByTableName: [String: [ForeignKey]], From 820347ad1f72e4e8a65f62cd6ec9af0433ca39c4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 14 Jun 2025 10:00:22 -0700 Subject: [PATCH 133/581] wip --- Examples/Reminders/RemindersApp.swift | 1 + Package.resolved | 6 +- Package.swift | 2 +- README.md | 2 +- .../Documentation.docc/Articles/CloudKit.md | 186 ++++++++++++++++++ .../Articles/ComparisonWithSwiftData.md | 102 ++++++++++ .../Documentation.docc/SharingGRDBCore.md | 3 +- 7 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index ff216e96..a5f57a3b 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -75,3 +75,4 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } } + diff --git a/Package.resolved b/Package.resolved index b413e929..17b9d627 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "28f3f4908b86340cd5b87bd5b7a5f67829d8e28d9117c135ff3088826aca6ae8", + "originHash" : "a534be697c5a5dde86d3f510df8d5ac32f64626136b0d5a55819d56fc0295499", "pins" : [ { "identity" : "combine-schedulers", @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "pkt-pat", - "revision" : "d526793f30b8ecab73071af5624ea483ad7ffd26" + "branch" : "temp-triggers", + "revision" : "4b33440ba8f3f797dd66f1e15ff5c4fc3cef2492" } }, { diff --git a/Package.swift b/Package.swift index 1ef18fd2..def5fcaf 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.0.0"), //.package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.4.0"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "pkt-pat"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "temp-triggers"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ diff --git a/README.md b/README.md index 34ccadfd..af132184 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ var items var items @FetchOne(Item.count()) -var inStockItemsCount = 0 +var itemsCount = 0 ``` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md new file mode 100644 index 00000000..f07d3078 --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -0,0 +1,186 @@ +# CloudKit synchronization + +Learn how to seamlessly add CloudKit synchronization and record sharing to your SharingGRDB +application. + +## Overview + +SharingGRDB allows you to seamlessly synchronize your SQLite database with CloudKit. After a few +steps to set up your project and a ``SyncEngine`` your database can be automatically synchronized +to CloudKit. However, distributing your app's schema across many devices is an impactful decision +to make, and so an abundance of care must be taken to make sure all devices remain consistent +and capable of communicating with each other. Please read the documentation closely and thoroughly +to make sure you understand how to best prepare your app for cloud synchronization. + +## Setting up your project + +The steps to set up your SharingGRDB project for CloudKit synchronization are the +[same for setting up][setup-cloudkit-apple] any other kind of project for CloudKit: + +* Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project. +* Follow the [Configuring background execution modes] guide for adding the Background Modes +capability to your project. +* If you want enable sharing of records with other iCloud users, be sure to add a +`CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented +in [Apple's documentation for sharing]. + +With those steps completed you are ready to configure a ``SyncEngine`` that will facilitate +synchronizing your database to and from CloudKit. + +[Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic +[setup-cloudkit-apple]: https://developer.apple.com/documentation/swiftdata/syncing-model-data-across-a-persons-devices#Add-the-iCloud-and-Background-Modes-capabilities +[Configuring iCloud services]: https://developer.apple.com/documentation/Xcode/configuring-icloud-services +[Configuring background execution modes]: https://developer.apple.com/documentation/Xcode/configuring-background-execution-modes + +## Setting up a SyncEngine + +The foundational tool used to synchronize your SQLite database to CloudKit is a ``SyncEngine``. +This is a wrapper around CloudKit's `CKSyncEngine` and performs all the necessary work to listen +for changes in the database to play them back to CloudKit, and listen for changes in CloudKit to +play them back to SQLite. + +Before constructing a ``SyncEngine`` you must have already created and migrated your app's local +SQLite database as detailed in . Immediately after that is done in the +`prepareDependencies` of the entry point of your app you will override the +``Dependencies/DependencyValues/defaultSyncEngine`` dependency with a sync engine that specifies +the CloudKit container to use, the database to synchronize, as well as the tables you want to +synchronize: + +```swift +@main +struct MyApp: App { + init() { + try! prepareDependencies { + $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + container: CKContainer( + identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" + ), + database: $0.defaultDatabase, + tables: [ + RemindersList.self, + Reminder.self, + ] + ) + } + } + + … +} +``` + +> Important: A few important things to note about this: +> +> * The CloudKit container identifier must be explicitly provided and unfortuantely cannot be +> extracted from Entitlements.plist automatically. That priviledge is only afforded to SwiftData. +> * You must explicitly provide all tables that you want to synchronize. We do this so that you can +> have the option of having some local tables that are not synchronized to CloudKit. + +Once this work is done the app should work exactly as it did before, but now any changes made +to the database will be synchronized to CloudKit. You will still interact with your local SQLite +database in the same as you always do. You can use ``FetchAll`` to fetch data to be used in a view +or `@Observable` model, and you can use the `defaultDatabase` dependency to write to the database. + +## Designing your schema with synchronization in mind + +Distributing your app's schema across many devices is a big decision to make for your app, and +care must be taken. It is not true that you can simply take any existing schema, add a +``SyncEngine`` to it, and have it magically synchronize data across all devices and across all +versions of your app. There are a number of principals to keep in mind while designing and evolving +your schema to make sure every device can synchronize changes to every other device, no matter the +version. + +#### Primary keys + +> Important: Primary keys must be UUIDs with a default, and further we recommend specifying a "NOT NULL" +> constraint with a "ON CONFLICT REPLACE" action. + +Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a +primary key by using an "autoincrement" integer. This makes it so that newly inserted rows get +a unique ID by simply adding 1 to the largest ID in the table. However, that does not play nicely +with distributed schemas. That would make it possible for two devices to create a record with +`id: 1`, and when those records synchronize there would be an irreconcilable conflict. + +For this reason, primary keys in SQLite tables should be globally unique, and so SharingGRDB +requires that they be UUIDs. We recommend stores UUIDs in SQLite a "TEXT" column, adding a default +with a freshly generated UUID, and further adding a "ON CONFLICT REPLACE" constraint: + +```sql +CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + … +) +``` + +This will make it possible to create new records using the `Draft` type afforded to primary +keyed tables without needing to specify an `id`: + +```swift +try database.write { db in + try Reminder.upsert { Reminder.Draft(title: "Get milk") } + .execute(db) +} +``` + +#### Primary keys on every table + +> Important: Every table synchronized must have a single, non-compound primary key to aid in +> synchronization, even if it is not used by your app. + +_Every_ table being synchronized must have a single primary key and cannot have compound primary +keys. This includes join tables that typically only have two foreign keys pointing to the two +tables they are joining. For example, a `ReminderTag` table that joins reminders to tags should be +designed like so: + +```sql +CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE +) +``` + +Note that the `id` column may not ever be used in your application code, but it is necessary to +facilitate synchronizing to CloudKit. + +#### Foreign key relationships + +> Important: Foreign key constraints must be disabled for your SQLite connection, but you can still +> use references with "ON DELETE" and "ON UPDATE" actions. + +SharingGRDB can synchronize one-to-one, many-to-one and many-to-many to CloudKit, however one +cannot _enforce_ foreign key constraints. Recall that foreign key constraints allow you to say +when one table references a row from another table. For example, a reminder can belong to a +reminders list, and the following schema expresses this relationship: + +```sql +CREATE TABLE "reminders" ( + … + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE +) +``` + +This expresses a one-to-many relationship (i.e. one reminders list can have many reminders), +and typically we like to _enforce_ this relationship by not allowing one to create a reminder +with a `remindersListID` that does not exist in the database. + +However, this constraint does not play nicely with distributed schemas. We cannot guarantee the +order that reminders and lists are synchronized to the device, and so there will be times that a +reminder is synchronized to the device without its associated list, and then a few moments later +the list will also be synchronized. + +## Sharing records with other iCloud users + +#### Foreign key relationships + +Relationships between models + +## How SharingGRDB handles distributed schema scenarios + +## Preparing an existing schema for synchronization + +### Convert Int primary keys to UUID + +### Add primary key to all tables + + diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md index 8a69f36a..adb90cc6 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -20,6 +20,7 @@ associations, and more. * [Migrations](#Migrations) * [Lightweight migrations](#Lightweight-migrations) * [Manual migrations](#Manual-migrations) + * [CloudKit](#CloudKit) * [Supported Apple platforms](#Supported-Apple-platforms) ### Defining your schema @@ -774,6 +775,107 @@ So, while lightweight migrations are one of the "magical" features of SwiftData, complex "manual" migrations are common enough that one should optimize for them rather than the other way around. +### CloudKit + +Both SharingGRDB and SwiftData support basic synchronization of models to CloudKit so that data +can be made available on all of a user's devices. However, SharingGRDB also supports sharing records +with other iCloud users, and it exposes the underlying CloudKit data types (e.g. `CKRecord`) so +that you can interact directly with CloudKit if needed. + +Setting up a database and sync engine in SharingGRDB isn't much different from setting up a +SwiftData stack with CloudKit. The main difference is that one must explicitly provide the +container identifier in SharingGRDB because SwiftData has been privileged in being able to +inspect the Entitlements.plist in order to automatically extract that information: + +@Row { + @Column { + ```swift + // SharingGRDB + @main + struct MyApp: App { + init() { + try! prepareDependencies { + $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + container: CKContainer( + identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" + ), + database: $0.defaultDatabase, + tables: [ + RemindersList.self, + Reminder.self, + ] + ) + } + } + + … + } + ``` + } + @Column { + ```swift + // SwiftData + @main + struct MyApp: App { + let modelContainer: ModelContainer + init() { + let schema = Schema([ + Reminder.self, + RemindersList.self, + ]) + let modelConfiguration = ModelConfiguration(schema: schema) + modelContainer = try! ModelContainer( + for: schema, + configurations: [modelConfiguration] + ) + } + + … + } + ``` + } +} + +Once this initial set up is performed, all insertions, updates and deletions from the database +will be automatically synchronized to CloudKit. + +SwiftData also has a few limitations in what features you are allowed to use in your schema: + +* Unique constraints are not allowed on columns. +* All properties on a model must be optional or have a default value. +* All relationships must be optional. + +SharingGRDB has the first two limitations due to the nature of a distributed nature of the app's +schema: + +* Unique constraints on columns (except for the primary key) cannot be upheld on a distributed +schema. For example, if you have a `Tag` table with a unique `title` column, then what +are you to do if two different devices create a tag with the title "family" at the same time? +* Properties on models must have a default. To see why this is necessary, consider if device A is +running with a schema in which `Reminder` has an `isFlagged` column and device B is running with a +schema that does not. When device B creates a record without the `isFlagged` value, and that record +is synchronized to device A, it will fail to insert into the database because there is not value +for `isFlagged`. + +However, SharingGRDB does not have the third limitation. Relationships can be non-optional since +they are modeled as simple foreign keys: + +```swift +@Table +struct Reminder { + … + var remindersListID: RemindersList.ID +} +``` + +This foreign key does not need to be optional because it can be synchronized without having +fetched the full reminders list that a reminder belongs to. + +For more information about requirements of your schema in order to use CloudKit synchronization, +see , and for more general +information about CloudKit synchronization, see . + ### Supported Apple platforms SwiftData and the `@Query` macro require iOS 17, macOS 14, tvOS 17, watchOS 10 and higher, and diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md index 4db1e024..f72be76f 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md @@ -121,7 +121,7 @@ This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies var items @FetchOne(Item.count()) - var inStockItemsCount = 0 + var itemsCount = 0 ``` } @Column { @@ -258,6 +258,7 @@ with SQLite to take full advantage of GRDB and SharingGRDB. - - - +- - - From 5d9f4f579832e477e922b503cb0ac7bff967b5b9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 14 Jun 2025 12:20:01 -0700 Subject: [PATCH 134/581] wip --- README.md | 3 +- .../Documentation.docc/SharingGRDB.md | 3 +- .../Documentation.docc/Articles/CloudKit.md | 316 ++++++++++++++++-- .../Articles/PreparingDatabase.md | 5 +- .../Documentation.docc/SharingGRDBCore.md | 6 +- 5 files changed, 307 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index af132184..cd8e655b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # SharingGRDB -A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL. +A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL and supporting +CloudKit synchronization. [![CI](https://github.com/pointfreeco/sharing-grdb/workflows/CI/badge.svg)](https://github.com/pointfreeco/sharing-grdb/actions?query=workflow%3ACI) [![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.pointfree.co/slack-invite) diff --git a/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md b/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md index 416a0572..e72c2440 100644 --- a/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md +++ b/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md @@ -1,6 +1,7 @@ # ``SharingGRDB`` -A fast, lightweight replacement for SwiftData, powered by SQL. +A fast, lightweight replacement for SwiftData, powered by SQL and supporting CloudKit +synchronization. ## Overview diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index f07d3078..77d4aeaa 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -6,12 +6,32 @@ application. ## Overview SharingGRDB allows you to seamlessly synchronize your SQLite database with CloudKit. After a few -steps to set up your project and a ``SyncEngine`` your database can be automatically synchronized +steps to set up your project and a ``SyncEngine``, your database can be automatically synchronized to CloudKit. However, distributing your app's schema across many devices is an impactful decision to make, and so an abundance of care must be taken to make sure all devices remain consistent and capable of communicating with each other. Please read the documentation closely and thoroughly to make sure you understand how to best prepare your app for cloud synchronization. - + + - [Setting up your project](#Setting-up-your-project) + - [Setting up a SyncEngine](#Setting-up-a-SyncEngine) + - [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) + - [UUID Primary keys](#UUID-Primary-keys) + - [Primary keys on every table](#Primary-keys-on-every-table) + - [Default values for columns](#Default-values-for-columns) + - [Unique constraints](#Unique-constraints) + - [Backwards compatible migrations](#Backwards-compatible-migrations) + - [Foreign key relationships](#Foreign-key-relationships) + - [Record conflicts](#Record-conflicts) + - [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) + - [Sharing root records](#Sharing-root-records) + - [Sharing foreign key relationships](#Sharing-foreign-key-relationships) + - [One-to-many relationships](#One-to-many-relationships) + - [Many-to-many relationships](#Many-to-many-relationships) + - [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) + - [How SharingGRDB handles distributed schema scenarios](#How-SharingGRDB-handles-distributed-schema-scenarios) + - [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) + - [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) + - [Add primary key to all tables](#Add-primary-key-to-all-tables) ## Setting up your project The steps to set up your SharingGRDB project for CloudKit synchronization are the @@ -20,11 +40,11 @@ The steps to set up your SharingGRDB project for CloudKit synchronization are th * Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project. * Follow the [Configuring background execution modes] guide for adding the Background Modes capability to your project. -* If you want enable sharing of records with other iCloud users, be sure to add a +* If you want to enable sharing of records with other iCloud users, be sure to add a `CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented in [Apple's documentation for sharing]. -With those steps completed you are ready to configure a ``SyncEngine`` that will facilitate +With those steps completed, you are ready to configure a ``SyncEngine`` that will facilitate synchronizing your database to and from CloudKit. [Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic @@ -71,14 +91,14 @@ struct MyApp: App { > Important: A few important things to note about this: > -> * The CloudKit container identifier must be explicitly provided and unfortuantely cannot be -> extracted from Entitlements.plist automatically. That priviledge is only afforded to SwiftData. +> * The CloudKit container identifier must be explicitly provided and unfortunately cannot be +> extracted from Entitlements.plist automatically. That privilege is only afforded to SwiftData. > * You must explicitly provide all tables that you want to synchronize. We do this so that you can > have the option of having some local tables that are not synchronized to CloudKit. Once this work is done the app should work exactly as it did before, but now any changes made to the database will be synchronized to CloudKit. You will still interact with your local SQLite -database in the same as you always do. You can use ``FetchAll`` to fetch data to be used in a view +database the same way you always have. You can use ``FetchAll`` to fetch data to be used in a view or `@Observable` model, and you can use the `defaultDatabase` dependency to write to the database. ## Designing your schema with synchronization in mind @@ -86,14 +106,14 @@ or `@Observable` model, and you can use the `defaultDatabase` dependency to writ Distributing your app's schema across many devices is a big decision to make for your app, and care must be taken. It is not true that you can simply take any existing schema, add a ``SyncEngine`` to it, and have it magically synchronize data across all devices and across all -versions of your app. There are a number of principals to keep in mind while designing and evolving +versions of your app. There are a number of principles to keep in mind while designing and evolving your schema to make sure every device can synchronize changes to every other device, no matter the version. -#### Primary keys +#### UUID Primary keys -> Important: Primary keys must be UUIDs with a default, and further we recommend specifying a "NOT NULL" -> constraint with a "ON CONFLICT REPLACE" action. +> Important: Primary keys must be UUIDs with a default, and further we recommend specifying a +> "NOT NULL" constraint with a "ON CONFLICT REPLACE" action. Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a primary key by using an "autoincrement" integer. This makes it so that newly inserted rows get @@ -102,8 +122,8 @@ with distributed schemas. That would make it possible for two devices to create `id: 1`, and when those records synchronize there would be an irreconcilable conflict. For this reason, primary keys in SQLite tables should be globally unique, and so SharingGRDB -requires that they be UUIDs. We recommend stores UUIDs in SQLite a "TEXT" column, adding a default -with a freshly generated UUID, and further adding a "ON CONFLICT REPLACE" constraint: +requires that they be UUIDs. We recommend storing UUIDs in SQLite as a "TEXT" column, adding a +default with a freshly generated UUID, and further adding a "ON CONFLICT REPLACE" constraint: ```sql CREATE TABLE "reminders" ( @@ -124,7 +144,7 @@ try database.write { db in #### Primary keys on every table -> Important: Every table synchronized must have a single, non-compound primary key to aid in +> Important: Each synchronized table must have a single, non-compound primary key to aid in > synchronization, even if it is not used by your app. _Every_ table being synchronized must have a single primary key and cannot have compound primary @@ -140,17 +160,38 @@ CREATE TABLE "reminders" ( ) ``` -Note that the `id` column may not ever be used in your application code, but it is necessary to +Note that the `id` column may never be used in your application code, but it is necessary to facilitate synchronizing to CloudKit. +#### Default values for columns + +> Important: All columns must have a default in order to allow for multiple devices to run your +> app with different versions of the schema. + + + +#### Unique constraints + +> Important: SQLite tables cannot have "UNIQUE" constraints on their columns in order to allow +> for distributed creation of records. + + + +#### Backwards compatible migrations + +> Important: A database migrations should be done carefully and with full backwards compatibility +> in mind in order to support multiple devices running with different schema versions. + + + #### Foreign key relationships > Important: Foreign key constraints must be disabled for your SQLite connection, but you can still > use references with "ON DELETE" and "ON UPDATE" actions. -SharingGRDB can synchronize one-to-one, many-to-one and many-to-many to CloudKit, however one -cannot _enforce_ foreign key constraints. Recall that foreign key constraints allow you to say -when one table references a row from another table. For example, a reminder can belong to a +SharingGRDB can synchronize one-to-one, many-to-one, and many-to-many relationships to CloudKit, +however one cannot _enforce_ foreign key constraints. Recall that foreign key constraints define +when one table references a row in another table. For example, a reminder can belong to a reminders list, and the following schema expresses this relationship: ```sql @@ -167,13 +208,246 @@ with a `remindersListID` that does not exist in the database. However, this constraint does not play nicely with distributed schemas. We cannot guarantee the order that reminders and lists are synchronized to the device, and so there will be times that a reminder is synchronized to the device without its associated list, and then a few moments later -the list will also be synchronized. +the list will also be synchronized. We must allow for this intermiedate period of inconsistency +as we wait for the system to become eventually consistent. + +> Note: It is OK for foreign keys can be "NOT NULL" in your schema, but your queries and UI should +> be built in a way that is resillient to there being times when the foreign key points to a row +> that does not yet exist. This means that when performing a full join between tables you may +> not get any results until all data has been synchronized, or when performing a left join +> you will have to deal with optional values. + +So, when creating and migrating your database, you must disable foreign key checks. This is done +in GRDB like so: + +```diff + func appDatabase() throws -> any DatabaseWriter { + let database: any DatabaseWriter + var configuration = Configuration() ++ configuration.foreignKeysEnabled = false + … + } +``` + +This unfortunately turns off _all_ functionality of foreign keys. But, there are two parts to +foreign keys: there is the constraint, which prevents creating rows that reference other rows +that do not exist, and there's the action, which allows you to perform an action when a foreign +key is updated (such as cascading deletions). The former is incompatible with distributed schemas, +but the latter is perfectly fine. + +For this reason, SharingGRDB recreates foreign key actions so that you can still take advantage of +"ON UPDATE" and "ON DELETE" clauses. This means that you can continue using foreign keys +in your table schema: + +```sql +CREATE TABLE "reminders" ( + … + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE +) +``` + +…and while the constraint will not be enforced, the "ON DELETE CASCADE" will still be implemented, +i.e. when a reminders list is deleted, all of its associated reminders will also be deleted, +and everything will be synchronized to all devices. + +#### Record conflicts + +> Important: Conflicts are handled automatically by letting most recently edited records overwrite +> older records. + +Conflicts between record edits will inevitably happen, and it's just a fact of dealing with +distributed data. The library handles conflicts automatically, but does so in the most naive way +possible (which is also the strategy of SwiftData). When a record is synchronized to a device, +the ``SyncEngine`` checks a last modified timestamp on the new record and the record it currently +has on device, and it chooses the one with the newest timestamp. + +There is no per-field synchronization, nor is there more advanced CRDT synchronization. We may +allow for these kinds of strategies in the future, but for now "last edit wins" is the only +strategy available and we feel serves the needs of the most number of people. ## Sharing records with other iCloud users -#### Foreign key relationships +SharingGRDB provides the tools necessary to share a record with another iCloud user so that +multiple users can collaborate on a single record. Sharing a record with another user brings +extra complications to an app that go beyond the existing complications of sharing a schema +across many devices. Please read the documentation carefully and thoroughly to understand +how to best situate your app for sharing that does not cause problems down the road. + +> Note: To enable sharing of records be sure to add a `CKSharingSupported` key to your Info.plist +with a value of `true`. This is subtly documented in [Apple's documentation for sharing]. + +[Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic + +To share a record with another user one must first create a `CKShare`. SharingGRDB provides +a method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` +for a record. Further, the value returned from this method can be stored in a view and be used +to drive a sheet to display a ``CloudSharingView``, which is a wrapper around UIKit's +`UICloudSharingController`: + +```swift +struct RemindersListView: View { + let remindersList: RemindersList + @State var sharedRecord: SharedRecord? + + var body: some View { + Form { + … + } + .toolbar { + Button("Share") { + Task { + await withErrorReporting { + sharedRecord = try await syncEngine.share(record: remindersList) { share in + share[CKShare.SystemFieldKey.title] = "Join '\(remindersList.title)!'" + } + } + } + } + } + .sheet(item: $sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } + } +} +``` + +When the "Share" button is tapped, a ``SharedRecord`` will be generated and stored as local state +in the view. That will cause a ``CloudSharingView`` sheet to be presented where the user can +configure how they want to share the record. A record can be _unshared_ by presenting the same +``CloudSharingView`` to the user so that they can tap the "Stop sharing" button in the UI. + +#### Sharing root records + +> Important: It is only possible to share "root" records, that is, records with no +> non-optional foreign keys. + +A record can be shared only if it is a "root" record. That means it cannot have any non-optional +foreign keys. As an example, the following `RemindersList` table is a root record because it does +not have any fields pointing to other tables: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} +``` + +On the other hand, a `Reminder` table with a foreign key pointing to the `RemindersList` is _not_ +a root record: + +```swift +@Table +struct Reminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var remindersListID: RemindersList.ID +} +``` + +Such records cannot be shared because it is not appropriate to also share the parent record +(i.e. the reminders list). For example, suppose you have a list named "Personal" with a reminder +"Get milk". You share this reminder with someone, who then wants to rename the list to "Life". +Would you want your list to also be renamed even though you did not explicitly share the list? +Or what if they delete the list? Would you want that to delete your list and all the reminders +in the list? + +For those reasons it is not possible to share non-root records, like reminders. Instead, you can +share root records, like reminders lists. If you do invoke ``SyncEngine/share(record:configure:)`` +with a non-root record, a ``SyncEngine/CantShareRecordWithParent`` error will be thrown. + +#### Sharing foreign key relationships + +> Important: Foreign key relationships are automatically synchronized, but only if the related +> record has a single non-optional foreign key. Records with multiple foreign keys cannot be +> synchronized. + +Relationships between models will automatically be shared when sharing a root record, but with +some limitations. An associated record of a shared record will only be shared if it has exactly +one non-optional foreign key pointing to the shared record. + +##### One-to-many relationships + +One-to-many relationships are the simplest to share with other users. As an example, +consider a `RemindersList` table that can have many `Reminder`s associated with it: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} + +@Table +struct Reminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var remindersListID: RemindersList.ID +} +``` + +Since `RemindersList` is a [root record](#Sharing-root-records) it can be shared, and since +`Reminder` has only one non-optional foreign key pointing to `RemindersList`, it too will be +shared. + +Further, suppose there was a `ChildReminder` table that had a single foreign key pointing to a +`Reminder`: + +```swift +@Table +struct ChildReminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var parentReminderID: Reminders.ID +} +``` + +This too will be shared because it has one single foreign key pointing to a table that also has +one single foreign key pointing to the root record being shared. + +##### Many-to-many relationships + +Many-to-many relationships pose a significant problem to sharing and cannot be supported. However, +if a table has multiple non-optional foreign keys, then it will not be shared even if one of those +foreign keys points to the shared record. + +> Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing +> many-to-many relationships. + +To see why many-to-many relationships can be problematic, suppose we had a many-to-many association +of a `Tag` table to `Reminder` via a `ReminderTag` join table: + +```swift +@Table +struct Tag: Identifiable { + let id: UUID + var title = "" +} +@Table +struct ReminderTag: Identifiable { + let id: UUID + var reminderID: Reminder.ID + var tagID: Tag.ID +} +``` + +The `ReminderTag` records will _not_ be shared, and as a consequence the `Tag` records will also +not be shared, even though `ReminderTag` points to `Reminder` which is shared. Sharing these records +cannot be done in a consistent and logical manner. + +For example, suppose you share a "Personal" list with someone, which holds a "Get milk" reminder, +and that reminder has a "weekend" tag associated with it. If the tag were shared with your friend, +then what happens when they delete the tag? Would it be appropriate to delete that tag from all of +your reminders, even the ones that were not shared? For these reasons, and more, records +with multiple non-optional foreign keys cannot be shared with a record. + + + -Relationships between models +## Accessing CloudKit metadata ## How SharingGRDB handles distributed schema scenarios diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md index d23deabe..4612a5a2 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md @@ -35,7 +35,7 @@ func appDatabase() -> any DatabaseWriter { ### Step 2: Create configuration Inside this static variable we can create a [`Configuration`][config-docs] value that is used to -configure the database. We highly recommend always turning on +configure the database. We recommend turning on [foreign key](https://www.sqlite.org/foreignkeys.html) constraints to protect the integrity of your data: @@ -46,6 +46,9 @@ data: } ``` +> Important: If you are synchronizing your database to CloudKit, then you must not enable +> foreign keys. See for more information. + This will prevent you from deleting rows that leave other rows with invalid associations. For example, if a "teams" table had an association to a "sports" table, you would not be allowed to delete a sports row unless there were no teams associated with it, or if you had specified a diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md index f72be76f..40363056 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md @@ -1,10 +1,12 @@ # ``SharingGRDBCore`` -A fast, lightweight replacement for SwiftData, powered by SQL. This module is automatically imported -when you `import SharingGRDB`. +A fast, lightweight replacement for SwiftData, powered by SQL and supporting CloudKit +synchronization. ## Overview +> Important: This module is automatically imported when you `import SharingGRDB`. + SharingGRDB is a [fast](#Performance), lightweight replacement for SwiftData that deploys all the way back to the iOS 13 generation of targets. From d89d7fe7902f6ce9828762c9e01a2eecda0194fd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 14 Jun 2025 12:26:10 -0700 Subject: [PATCH 135/581] fixes --- .../Documentation.docc/Articles/CloudKit.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 77d4aeaa..c6cca3a0 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -112,7 +112,7 @@ version. #### UUID Primary keys -> Important: Primary keys must be UUIDs with a default, and further we recommend specifying a +> Important: Primary keys must be UUIDs with a default, and further, we recommend specifying a > "NOT NULL" constraint with a "ON CONFLICT REPLACE" action. Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a @@ -179,7 +179,7 @@ facilitate synchronizing to CloudKit. #### Backwards compatible migrations -> Important: A database migrations should be done carefully and with full backwards compatibility +> Important: Database migrations should be done carefully and with full backwards compatibility > in mind in order to support multiple devices running with different schema versions. @@ -208,13 +208,13 @@ with a `remindersListID` that does not exist in the database. However, this constraint does not play nicely with distributed schemas. We cannot guarantee the order that reminders and lists are synchronized to the device, and so there will be times that a reminder is synchronized to the device without its associated list, and then a few moments later -the list will also be synchronized. We must allow for this intermiedate period of inconsistency +the list will also be synchronized. We must allow for this intermediate period of inconsistency as we wait for the system to become eventually consistent. -> Note: It is OK for foreign keys can be "NOT NULL" in your schema, but your queries and UI should -> be built in a way that is resillient to there being times when the foreign key points to a row +> Note: It is OK for foreign keys to be "NOT NULL" in your schema, but your queries and UI should +> be built in a way that is resilient to times when the foreign key points to a row > that does not yet exist. This means that when performing a full join between tables you may -> not get any results until all data has been synchronized, or when performing a left join +> not get any results until all data has been synchronized, or when performing a left join, > you will have to deal with optional values. So, when creating and migrating your database, you must disable foreign key checks. This is done @@ -279,7 +279,7 @@ with a value of `true`. This is subtly documented in [Apple's documentation for [Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic To share a record with another user one must first create a `CKShare`. SharingGRDB provides -a method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` +the method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` for a record. Further, the value returned from this method can be stored in a view and be used to drive a sheet to display a ``CloudSharingView``, which is a wrapper around UIKit's `UICloudSharingController`: From 094e4084f4c588a163da34fafb9b071d1f9f0399 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 15 Jun 2025 10:20:58 -0700 Subject: [PATCH 136/581] wip --- .../Documentation.docc/Articles/CloudKit.md | 122 ++++++++++++++++-- .../sync-diagram-many-to-many-refactor.png | Bin 0 -> 38257 bytes .../Resources/sync-diagram-many-to-many.png | Bin 0 -> 52918 bytes ...sync-diagram-one-to-at-most-one-unique.png | Bin 0 -> 46438 bytes .../Resources/sync-diagram-one-to-many.png | Bin 0 -> 56791 bytes .../Resources/sync-diagram-root-record.png | Bin 0 -> 67533 bytes 6 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png create mode 100644 Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-many-to-many.png create mode 100644 Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png create mode 100644 Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-many.png create mode 100644 Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-root-record.png diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index c6cca3a0..4e740187 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -137,7 +137,10 @@ keyed tables without needing to specify an `id`: ```swift try database.write { db in - try Reminder.upsert { Reminder.Draft(title: "Get milk") } + try Reminder.upsert { + // Do not provide 'id', let database initialize it for you. + Reminder.Draft(title: "Get milk") + } .execute(db) } ``` @@ -357,15 +360,34 @@ For those reasons it is not possible to share non-root records, like reminders. share root records, like reminders lists. If you do invoke ``SyncEngine/share(record:configure:)`` with a non-root record, a ``SyncEngine/CantShareRecordWithParent`` error will be thrown. +> Note: A reminder can still be shared as an association to a shared reminders list, as discussed +> [in the next section](). However, a single +> reminder cannot be shared on its own. + +For a more complex example, consider the following diagrammatic schema for a reminders app: + +![Root record diagram](sync-diagram-root-record.png) + +In this schema, a `RemindersList` can have many `Reminder`s, can have a `CoverImage`, and a +`Reminder` can have multiple `Tag`s, and vice-versa. The only table in this diagram that constitutes +a "root" is `RemindersList`. It is the only one with no non-optional foreign key relationships. +None of `Reminder`, `CoverImage`, `Tag` or `ReminderTag` can be directly shared on their own +because they are not root tables. + #### Sharing foreign key relationships > Important: Foreign key relationships are automatically synchronized, but only if the related -> record has a single non-optional foreign key. Records with multiple foreign keys cannot be -> synchronized. +> record has a single non-optional foreign key without a uniqueness constraint. Records with +> multiple foreign keys or uniqueness constraints cannot be synchronized. Relationships between models will automatically be shared when sharing a root record, but with some limitations. An associated record of a shared record will only be shared if it has exactly -one non-optional foreign key pointing to the shared record. +one non-optional foreign key pointing to the root shared record, whether directly or indirectly +through other records satisfying this property. + +Below we describe some of the most common types of relationships in SQL databases, as well as +which are possible to synchronize, which cannot be synchronized, and which can be adapted to +play nicely with synchronization. ##### One-to-many relationships @@ -408,10 +430,20 @@ struct ChildReminder: Identifiable { This too will be shared because it has one single foreign key pointing to a table that also has one single foreign key pointing to the root record being shared. +As a more complex example, consider the following diagrammatic schema: + +![Synchronizing one-to-many relationships](sync-diagram-one-to-many.png) + +In this schema, a `RemindersList` can have many `Reminder`s and a `CoverImage`, and a `Reminder` +can have many `ChildReminder`s. Sharing a `RemindersList` will share all associated reminders, +cover image, and even child reminderes. The child reminders are synchronized because it has a +single non-optional foreign key pointing to a table that also has a single non-optional foreign +key pointing to the root record. + ##### Many-to-many relationships -Many-to-many relationships pose a significant problem to sharing and cannot be supported. However, -if a table has multiple non-optional foreign keys, then it will not be shared even if one of those +Many-to-many relationships pose a significant problem to sharing and cannot be supported. If a +table has multiple non-optional foreign keys, then it will not be shared even if one of those foreign keys points to the shared record. > Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing @@ -434,27 +466,91 @@ struct ReminderTag: Identifiable { } ``` -The `ReminderTag` records will _not_ be shared, and as a consequence the `Tag` records will also -not be shared, even though `ReminderTag` points to `Reminder` which is shared. Sharing these records -cannot be done in a consistent and logical manner. +In diagrammatic form, this schema looks like the following: + +![Synchronizing many-to-many relationships](sync-diagram-many-to-many.png) -For example, suppose you share a "Personal" list with someone, which holds a "Get milk" reminder, -and that reminder has a "weekend" tag associated with it. If the tag were shared with your friend, -then what happens when they delete the tag? Would it be appropriate to delete that tag from all of -your reminders, even the ones that were not shared? For these reasons, and more, records +The `ReminderTag` records will _not_ be shared because it has two non-optional foreign key +relationships, represented by the two arrows leaving the `ReminderTag` node. As a consequence, +the `Tag` records will also not be shared. Sharing these records cannot be done in a consistent and +logical manner. + +To see the problem, suppose you share a "Personal" list with someone, which holds a "Get milk" +reminder, and that reminder has a "weekend" tag associated with it. If the tag were shared with your +friend, then what happens when they delete the tag? Would it be appropriate to delete that tag from +all of your reminders, even the ones that were not shared? For these reasons, and more, records with multiple non-optional foreign keys cannot be shared with a record. +If you want to support many tags associated with a single reminder, you will have no choice +but to turn it into a one-to-many relationship so that each tag belongs to exactly one reminder: + +```swift +@Table +struct Tag: Identifiable { + let id: UUID + var title = "" + var reminderID: Reminder.ID +} +``` + +In diagrammatic form this schema now looks like the following: +![Many-to-many refactor into a one-to-many relationship](sync-diagram-many-to-many-refactor.png) +This kind of relationship will now be synchronized automatically. Sharing a `RemindersList` will +automatically share all of its `Reminder`s, which will subsequently also share all of their +`Tag`s. But, this does put responsibility on your application code to properly aggregate +multiple tags together with the same titles. Luckily this is something that SQL excels at. + +##### One-to-"at most one" relationships + +One-to-"at most one" relationships in SQLite allow you to associate zero or one records with +another record. For an example of this, suppose we wanted to hold onto a cover image for reminders +lists (see for more information on synchronizing assets such as images). It +is perfectly fine to hold onto large binary data in SQLite, such as image data, but typically one +should put this data in a separate table. + +This kind of relationship can be modeled in SQLite as a foreign key pointing from image record +to reminders list record, and with a uniqueness constraint on the key. That enforces that at +most one image is associated with a reminders list. + +In diagrammatic form, it looks like this: + +![One-to-"at most one" relationship with uniqueness](sync-diagram-one-to-at-most-one-unique.png) + +Here the `CoverImage` table has a foreign key pointing to the root table `RemindersList`, but +with a uniqueness constraint to enforce that at most one cover image belongs to a list. + +However, due to what is discussed in , this kind of relationship +cannot be synchronized to CloudKit since uniqueness constraints do not play nicely with +distributed data. But, one can still model this kind of relationship by not enforcing the +uniqueness constraint in SQL and instead enforcing it in your application logic. This means +you will model the relationship as a one-to-many (as described in +) and making sure that in your feature's logic you never +create multiple cover images pointing to the same reminders list. + +## Assets + + ## Accessing CloudKit metadata + + ## How SharingGRDB handles distributed schema scenarios + + ## Preparing an existing schema for synchronization + + ### Convert Int primary keys to UUID + + ### Add primary key to all tables + + diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png b/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png new file mode 100644 index 0000000000000000000000000000000000000000..6b05e4024234af7d05fb818b786157e4433604f2 GIT binary patch literal 38257 zcmeFZbyStl+cydb5>k@VNUFf5yStH+7Nono8w3;t#7(ClAt1eJ>5xuoL6Gk5Gy5CA z=Q;0t&N~0RYn^`{*4plS&pmV1TyxFL^_ekBRYevHgA@Y+0Rc;1PD&jC0SN*=cW4hm zNwizm0RjT1ytSmHs=TBmwW^Dwg|(eI0)kvrk~XT2Mjuh8zWP&SG$}EK4Q1STJTV0{ zmY@@ww+eDdVR)tzsug)L7-g@ezU7usM^oTd7;6)C?dcIcdcm}9(pJ`lj_5X{-NJim z@n=}n@{WIfzTO-myY4uDj9e2x$Ur`mnc-&^H{^MW^cFJm1?>YYwsh7Hiu`J7%Lt!> z@AlUgl!KmK+hQw}2FBh`=h74Op&(GJ;eJ713o&?$gA~Mz{<%^J;jMaa+RMSj7l$@) z-OWbC-yRn07CsNj+Dj=t^;7*^XfolT)JY!&&V^MM44#!k2rTL z!BPMC0S|}tI}V~pZ_R1szqKk*F)2e)x=!Zy2a<3)V>5aQj%ltACz}RfxUb5-F(L(I z-LnyW)u&tT#MyYe`nw&~O+Q5W@?As;{A|& zO})IC8| zG@)h>dLn^IGrRH-hO-wQ*)fHqWtslo7qjzlA@ktlh>VvMOx%ibK7|hQ5$n)}*(&l% z{Pt4*nq3q|IN{cH>+D#Fo#f2mfI#j<48uk}4`*tW_%yd)$HDaV>1<%kuSli|M_K3G z?2pC-qS1pRDWo2xWuY(n4epbvlFF?^FaKc2V!qD0mOKmphC^l(#nO)bX8l{@Pn56d zC8*3hd{aR+Z)B6aoO*;e=bRsube&u#V!21`rR-~KrO4N7yw~_LPCTiTX{DEWWU;F9 zNd+3k$#JYhe$kCv;e{9c8|_n7ViglY|9RZj;Tc22esc($ev9jIAnNyPFGt7y_mh+5vi?Fv5`Xp< z*_j$$DQwZQ-5>HmqfLnohaed58~InnwKw=bF^a!Ce50BOWpKhvM}dAPbb2r!-uUG~ z6T-qTii>yDXy`dlF{P;3#hD-bc9Iy=ipWvp#dcE5%e_blmcvEiz)y+$NX-=QV)jV= z{f;CyEo1y6IWiYi@3(F=cha~AVj)HF0WHI7wdwD3y?UomOSm3SEL z=FG~v+q(<8m-$3DNcb0BVV~k}c}^^iSaC6>Qn93blGRrzDagnb&h{U9W5s84z!gU7 z%JqV4i>u70>9fYhCpT<=n&u!w1yQk9rTwwbR(jSh+?T0U+)P|Jwu@ssqb64G$D$|t zC-rh`G`2&gT%)JvkZevX! zkK=C=ZtspZ=A6rC@Am9|Q>8s4`cI=r#OwP%}rlh;NPTDC0E(H!w8f*5B>^oCb* zVq}P|+bLpMtAnV6lGZfu4Qux1e(cjF!C%b#z2`WEf5MeM$oMh-xzI(G- z$@i69i)5RtWNu=Ft5egsp{siGq$;IGw@Ei=hO+AVkEUy_cf+O4vBj~&Bm9^OB2E?? z=76ftI+ZM(EYo_KRep}<_DNPXBgKnb^);E?!wugXy00>^2D_&%qLuRp-Su->=6L2j zLU#!TCx#{=<7Mr8mBKdr7Ob^PZSJ$VlSleRl14_xD)l(^Nc8yh zKxtWI7~%}rkLG(FRmxQORp!$7V#g>61&Wc~T9{ujbEt91*dy92+S^`mtZ@ISwnM*jOo@yW7{^bkOi3lY zAapEr?t8bk_A6<(W_rVx!C629R>Tu&@}Y*OA%z{4=9sf92-)r>=$fby3=# zyo@9G>UhQYi;NHH%9QM=cwOedIztmM6FTLw`aZ9Qt?u>-|9;R@PO=oglB)jE#WmzK z%o-n}#Fd=PdALD$MR)i4K~dO5=R`)4(M9}?LcmK>R(pTFx*#SUCa;M(M{4! z(!*oTZ52m_e!655;869~ka!GheRluMm z%%*IcYNq$Yt#LEvizpM?iwpCIB9d%*-UXWj-kelJD+99}WGNXujoz`B=_sxOe;y8( zrpjhOh4-f=zO*NWJFYVhIU2mFezO9(a2V;rw6;q8C(O17Fo1Nl@ z$*+@LB}-)~)qf<46gLZMCTgNaT~+HgU7mgV#p0f%m6Wd^rGH!}vheBK?mX*`QE?ra9qY`Ud!(7H?<=Lr zO`G$rt&9jk=wvo`bn4LS=Z$4M*gqFkw^bX>aa>>a8Gh5QIFIu$x^~#ZnOpJ;^3OiY zUTZszd=eSG{h?vgec?3abQen=%RW*PhnLrfn^IuK%S<-WTBA#CA@4`5S{w`ClyB27 zuVM7fsIo*7Mk?NyHZ8t3^F`ZV@?O?uq6@m-*^SI-tn;nMW}S;HxwRd)FURb}oI>Xz zlQpnf@~g?yX~|NDQg`SAE3d&JZ0sjb!S2sjOC^luC%O(8t>_ioFXJD1KV?0s9#OV> z(F7jlo3?4pj*nR~3>Rk!iaKs#ZZ#eCT!;!7${G3Xy|~iY9zJEYH>AAzv0wga&v}8O z+^pQkh}Apq-l9hzS~n=HCXBPV)^dH*auIqLSsb?$w=O~-;B)4^MLY(rEUz^pYYq2r zoKBmYV_N%h;h%-{M3vHh4AHCk0fHVL0;ZS4=UD9woNJ6+f$_7a2U*{?FlV&q5embX zPDWoJZ2uub@U269gL1n096@Rn;q5WP#>Qx<`!D8c&pk~d8X+>f*sIh}X%Fv|)iUG* z4}7g~J{CjXSlzQK$%O2aR2^=@_ZH?l@)pX<2rs}h8UivRDFO<3LIfWXM6&-p%OE~O zc<@&_5&}Y`H3ITKbyUDN{O<$!z}x)&{U9L%0Tukh10Sz!quxjz{=|B=?U@VggClbva<8@^Ru#X zuySy)fEp~W-VScYUMvo-w0{%%myVRVtEr2%lbf}p12vqkv5BL*n=lOxoY8;&{+83+ z%lf}KIk^6#7Eq8C{)Uwu!p8a^+MuZryi`Ed+RNNdSIXKR*bMLx;pO2G`m6r`_U6Ah z{rVufq|-z@RBng1#U zb{4@9V*O8_iC_@g$-$k4+*<0TCin(U2LD401phqy`y2kOPVkX$!V3XG3_)H>T+<72 zCj+I4u#2)s7KIGu0fA}-N@LanMe;KbPh(SB4A`_^aa z=Ap%Yrm*vHz2$Iyqi@UcrBfY4>;2l^{?Lu3MKl8$CL$7U5CSqa0wUVypzj1|Py(H( z7ZM167yd${Ht>23e?gYL{s)SxXVsCA4nqlsnHfFTp@luyIa zPETl2+?0R@?N5nELc;B(1Ox5NF50OtQ~7mFZoBz@kIJBxqV-?gERZ_(1G|KLm;KwY z!W4sOI2qBP1IZk9Ptl+Tlm_AmNbkh~JAXl10}dP0Fhv&?Z`YhWi(8Q>(0g;(H z_ICO_9vPU~#5b--NVw1B5th~QUNTV97;qSO-^frK9Hls+0v$x)I@rH+>MMNz7(&9$ zJP0hIfF}m~*qIm=i3U|lW^a9r2K8f@4+a)`2^X2{e?HO;PfRK8Auq5U_U1T$=y$Z@YcZc}G?2T^ zY&-i>&^@hozYxWfE%4^5v81C6ddm63zTPKo>Pt=d{n>c=pOQLR*s6vSdw9ydQB10b zMqLSCo4{e;_RkM9-DlPl=kPY5BiRbyet|=^$Qo*dui+Em1n}`oqmA#964!j!sem_`E&KmEe=J3ZymlW0x-_>Aq z8?{_Um0W++P_`cOIu{ssj@6z0%7O;9IN2AvsjN4LY|?tnb?tWH|e9-mN=Z%;ShQ zF6R}RLl^DLtM)(q&a2aK_g{ZLohp0h+F0WH#i1%~eCet@KFGQx$jO?aJoA&O?;hTX z*sj}ho5kd7Uc1nrtYPx|lP>oJEHy}h#dRio9 zO!HO^3tFaWQ7tFL7zJ>!E*^!Yc+P|ma-@};6xi{;atPBhSn$14J^t>^+xQWU*c2C1 z>J_E_33)kzBQ#0>b(o^{@(TZ=-^tpazLLFTXu)vGb#l%5Hidz!It2)(?l=AN@16Sc z!<_oo;=7o_6p2c0=E9twzl8Z6FIrmsDn>$l@f5j#=Ctnw>ZGJtt9TT^!|yph`%{3#izjaNUwfa# z6>MfPa7uo`7H>{VO&!>XYvnO&I;=?PANsgCTNSy<5y;E=>iWp{!9@44;pNESYN~nJ z3_t=J8g=!PFrY>)WVV+3ekK7oSE!tg8K;xxIeP^f(=#r!xXl;4Jw`hRKcqYb=OKd( z*B6(bgRFUhl{XfyH|^fm5yNsn&LUZ5_!+h`)Z@BNC@kL*8Q;q4?WYSK#)&?NoOkNx z)L)_^s*q;oMylZR5K} z;%kdb`O-u#E|d%PGdIoREzfzcXSJTi*XJyC{jp_2pnuzLe0U1w-4{Aia4MPjN;8Pi zSaYBBM^>TLD&sz1XUXiKcuPZw}~Bm=A%9p!d^U?S!AX&IH_pa>{T3$ z;T1Jx)^##!k-gSk-_-rHMLs{?_nc|RR%V8hHoWF+W&ktrXi%&dmUh;oxjUCKzSJ@` zU^l&AZ!A&U}M+40m96EpJ_y#b`PC#sUVs*lk)@I_RM!uIJ#w%{NIqapqv z&%Tc@Q}Oh??Re9*ZZlYT6`QBcg*#bW#20afDgJ1emZXE)@qTZ*wsI~1p5vKm;Zs&q z?MvKpzoXCTl=Q$gth-6e&&|lq@cik`EX3tY*P@^5zGAEIFxR8f23V_kdwbWCo#^0% zMa3zUv)1dlEzyx6efcwtwBdu5>dStza}nYJnbE5Ear29I0XIVe?#p8>UmCnf`;UZK z^TT3Ze#!H3^eL@*N2Ygmuvv%nINK8M(badE4p&Jkc1fJRX3-uA*A#E4{!gqJ|C4z^ zhl}UwWgnc>90xf=sW5V_3oYZhp)MVgrmYmFB7T4DkGIcFL)W(!1a|huT_bdWbg~~m z7}zzh4Z2Yja5{>fRMu>Acx)Yxs(ib#EW!8V5?tl2(RE7ddbd?(oQIR;{o|Be+V^?! zV~FG8eTdqAFV*jE!R4(-IFE?Yixjr$^_yLKG(36_A8327l`OZd)fsjWFUQ``22fOe zSBUvD|KkXyo&LvE#sm*oD1)`?bBMtz8QV(!`QUHpf@yVa1kQ2!$T<1wf%l$z?w|I> zo_d@w4puOe^%!jKR1OMkN8y9q?f63zaTCdLwdN&7O_&@Sxy^k-R@)WDvtCD;N8Otp zMA)OP39eH!F4|?#6B$d_7bQJj%1R<(f(6GAlfXj85-wZsSON4#H)cIYgwwW2B;< z#MHENG;4F?VNzJ6%`iH*JmvjqTs6J#%>yIOkRd08nD~Bw&zO3ty|Xs zp?XAh&yfH76l6^V;iw4f#MJV$N@fuFoV!jcz<<={`XDQG-O+0TlYc`(*hO3tf`=3y z62bp)|9=11Xgp3j{!KU;5yS;kcsV8J!RQLUoE(L~G*7T-cfVknr3Z;yav(YRJ|){v zA-84wJ-wDEp)Ji!jso`d?dEgi=RDiw-=sgZijF!>S(k7NGVeQ2s#%2FPo2aK+V>cl zQ?{P${qotjy-qW))8U4lG2+po4%eIi`hrgcITGfd!9LtDEz8dQn#WuxF&?_6?KWGC z`cPPW%YD;xn4~2+J%?uDU35Hku5c7a7-G4BHAPfocpiaJd7abqI>Tb-cKT^E_;yRT zNdjgzT3o9Pek`l^52P05y>jh{P0-%+usG0ei3q@MW%VqO;S$~L%S#c)ST>IM=A`?5 z&(U=?bX_X3!IlN(e!Q1z{&MQU`M`0~=mY+5HcStws$uhAT|Ez>dpc20qaS|I*fJ+2 zE*gdTTh#Jd^Hv$R+Aq+a8j{cYmwcD6*G&7E{qrl$DRoA}*j)>sD=Y)OYUIVBq+MHR zA7d)F>#-WNz686|T3o(5 zjAp0ZA2Di(Af=_yEwFd1A#bOL$`Y01LUgN(?eW|5g_d)3vYN~Ic^tPKFHD~q(^?Ly zFX!t=polzMem?1pK|J`2-*zEDocm!*fcqknt1~+BX@pgDb%>AogkWAa{Tg>nPo)3D zb5BX3I{_bN27_D)v8sBmRZV6;+wfQm^h>9^w`n1nCErT5cB($j+(y&-S2yMP)IHQ` zJLy=@3$dKF(-4tlvTM{Dy)Di?wI4K;&X0)J(`V&ITEv+vC1h5a#^0_LjTN9$0jjpM^)`u3ICLZT9uChbbc7aP&G3d~ebD^Fl zpLGopy%Ar$`De~FvYX#bU36d*HD}?RzF78G#+P-C?($TPz0srO=gdV8cBAE&)bf7kEzO>hGM0 zx*n`|XSwytox-EI*=sIw&^NBRc+TPvq$(M)Tt{u(*vIpAU))!8s2Y{OeTHjVgLAtEqRX3x{#D#e!Pc zr!T`HuRV`aV0stw^;*PC8CLjOZQa72pNUX&3fKSCX>9kocBUG^@wjYSuASx-VE=3mMDHj$|Uvvc2~Y};z+ zf5wd9M&WVt9s}xME;wX$y7EclOIg*!*q;0X$+}v@7x&FQhMdHXb4@9KY#QyYJoxUj zzl+K;QPkBvVy>+c4CIx78~+~#)ia&qFS&(qJ%%O@f+EmYsh^7`w=&)lHqnm{3x%fG zMqMtPY_b~#Sho85`M1}kHcxBm&UFcHBDrx1Vp%?E)(&2DkyYzYa z?5)!A?85NXv%SL2ZP(er!28oUlGA0dQGV+flJO<(Vrj;rEs6L?lo1I1lnVEYCqJqE zi5uT!^e6I>9aSDdAB3Yz0I`|#q?HjEUH1L#=c`EZ2t@UVz0c3q<`{O% zHD5IQG;Oh2-Aj4|3{YkX(+6PkmYI zw8$a?r#6Hg30AME1Bpv7*&PO0`TN#jC5woILV9gwv&C_?d|{$;`L);|S|jgcTL0Zc z4|QjhpgwDg93gQuN?XTw0egyMVh6v0uDG(7wDHtb*sTuoMdoM2a~m6P)@}I*`@MNc zt7o@gKX{lny&ursD@segdR#eZ?VHkeo#A-*twjo>aqj)2JVD}XF1)uc9FtlITcd~K zjY6Kf*^QeO10gcoeT9rqNmXi^MQ3MpbI#Elli$Z$ooPgMPv*J~zrO!A7q4eqAQc*c z!MojR%ErAY8V6}w->-ASoR(dqsOzM&G|CrTP7yD-HaTu!*3&O{y7UpPG4(v5Anb5w zX=G7ZWFZ%>S_iq`R357*?et>trHLS0R8v_XGD2%Vea*$GWc$a!%c0KJt+Zc3qvh%# z;C@D~&aRb*CEcNbMDLaVC8o94@AXm9*Pb8qC6i z>KtJR$m#&&NkF1~r7In#n1qm+O1_++`9qZzWW7IuEOO1~w0BL9A${K@cWT67ov|M= zpCSYkz@eV+a<8-=FRRmk|I8o@qnqTI06FGDdXWBL*<_$~3{<!S!Xqg;dbeOM;9v z3ZV6zO+D$A6igGHt*GxYM7{>|6l9uh;Av^ahQ401T zX_^y0hP*;jvT+GhRA-TF2B~m8kevP>RsN?}{>Q=o4~`X~7X}G>FSk$tDhmdF?V^wE zcx&Lj9+CK>J429D5$AyqVWd+Lv2{N{ffAHJ$S~WkE^|PGPB|<3y$DlWQI8_{E8>YAoi^l+Xl*F%KR7y6+47UVdfi41# zWL?XQNuiSwNof;TGJqkAnS+p#bV$R4Og(}W+~R+o`j}QGIq>e1@$F2xk!aQLSiy(W z>2OrriUmXp+rD^g0A|tR?%5yWY`P!P@d13>8Hm#<9xnv&V%cm}#~4}3@)*Xp#~@Gv zR5_p2=QDu0k&wcD^lv16UJIee0m(kXFf#Qow2xc%hi^Cjp;bx`lGQByV*t2A z3|4Pu5y5MQWFpG~bHM?1n~iuPBwQ!t{!RPC!v8b@sJAs1_ZScoG1$dN-_Pt{F17%B z)KiRg!wm|eRh0*V$^0wWME74Yi-Jt83Xh;D-`)baeihHE)akIk>1wOw?jtQ63#|m0 zL4Ugx9grUnsUs4HS8%_nYICOQ3yFZ66}^1aBe)~{jV}s;SwatB^kbwppF!{U1FfD% z;yD8%(Qx3QUD^y(MFK)#ym?FrIyMfdAf(a>g8K_b;5z9f&p}lpG_U2c5^Yvav)&Ix zCdW_UJ$wmly;eoT07PX#WHoRaV!?kh3YQI7r(Z@|3XnBGGK7u%R)?^!0K2cjQAx#U zFVJ)bxydP~C*RIY3JBLv2PDELfHyrt_ga>SQ@k8_i;jr)Q5MWCl#C{zDIxAbdpIQ& zBl|B2V}OLrN>XqMfigv5M9#p#3UFm|qzwR9W}r+Z+hcU#TPVPWOxf|sKrdxLEfwb_ zye|M4Tb)Vq96Zth9i+cI0go+A1ypUq%)oI8DIi7r*W70$WuS%)(59GP&IinbaY4k8 zffxOf9aw;g0$}n2F@YR(8{@&<^?urGkC`rj%Fe)1*}_;rOd#NM1OhS!?tw4_laMf| zg9WsD0a_Iw!~^?1lluOM*v9~4beR$u5Psx?`$P_O8v)!JZ^P4ol`w&D>|%6}0Ba~X zMgc|D28Dj0@UVi85l!q32V&TxawO222~gFGr#C|5V*9Hj?0-Rn3M8{-Kvixe#adh| zGtdve(&u^|5$z+z?180hI+6^Yj*Nf`SoQ&=IKip#Ntu8(MKYTm=u0}jS51m^3IO^d zRP+8IvCzk2Fs|e_YY6g|9I&G7PXd%+8TjZRP#jl5o5%vn-VHo|jFbW#rmrc=ni_fb z9ViATTCr5`#GPQy>C5JMv_Z*e4)XrJ3?9 zQm`3dNB4tr;vrHB5CLH0*2u_sV4;y9SEc$K?QRXEk0wPWC^WK|~Ce0&3EYF>Ce%5Aed<6reE} zGcT8@0Xy#8_GUUTMCsZvCUTwuvT9sW0U+j8d-oTU|K)8q4}i=(K`QZUpzX)u7xL_2 z5mYI&y%pkQO~I=^nF$m=d-psC15|tQtHxM8Op&ZTuE;7(QHi>b!2^{3j88C52kJVH zeKj&fgI-mXy^{Z{-Gun) zWjx}(+8?OPV;(!t>$!lm)*?dorf~Uq#F%1aVbypBAqh_n@XRoa6X2(FLPKLd!)-c9 zvo?te#?%+O9tjxTjKKTXAfB7fi5`|ZaxLdhT0~-zS@@joSQF`*#q-n*Cutj6Kg1^A z*kapnI!^tP70|GAMJEq?yE*6By1OL;kNSk#2BS*CrxWIN^yG*6P(zmz{_^ItS&LNb z0(8;hxy#1=mhG)u z=2X`qe%CRA2C@I8LxN@SAfRQVu1!(?z)R8FRWD3Rp`Uh5huzXM6IX3r#75+3(E2~A zKbp>FZOw^O=$2j17d+EIr!y8_9}kN<66w4N{?#y_$QCC}{CWa?z-7Ff#PRlYn%A-A zvfzjM`m|A?zslL@*&m^FKlbARPQkXS^F`mZ#b#gP==Owrlp-fibkKG3-;ms&DP+fo z{h*B#GVz$T{u0zhq;F?Du|2Bw^d?6J_M~djXS+TVbH=o|sDQ(<=`bS2G?w3leb{%u z+0p^F{IF`bb}khds=+MN84c=$nC8$>#J`^II$&2dNYnyw$DB6l1TYd3%&@i+WC5p` z2reQcKj-N#R^0Whz?K>B^^D`>iEV&fLr;pnWZFE$B3*Ux*q$1M&kiczSG$XMwz2~6 z*zsrro^7|@-SMQdmMV+I8B51S~AFTXT`v$j>;_YX#l4o5V8N`FA%;O(w8)I#uHNy*Lcn zickIt@Y{Aw33nHCu2iQ!d;dAZrv^5|t&8pS^EBw+9^eR#G##`*YTM0=73kl{0$Abq zcKLQ-89WQz;zUIdJo&%cM1+Mj)O^YC(DN*7eR3Le4&04%tHsDFCtPW|?Day`2F_ru zD)feT$?x}_-ltHD3Yf_5#%SLAhGWYN_o+7w{TGM77uedMMgi7D22SF<53CAOERN(u z^7==eLM+@14X=0?+{RIcZ=);%3o}Nk@6iWC4wvhe0ytZ5j$*8rTKr>0L-zV~H3H>r z&t8@rHV-gY4Xh;Ac-h(5a8UW5#2?RCO_ybi8ZD-dx(#wqYp$jXI{v9cIzuVsY%;Cq~j#^Ej z8vXqfjvvfs)HWwa6Dnv#r)M&fw1YEAYUt~c!t+hH!UJU-m(!S=3 z9n8b{CeZpZ^#>iKv%FC=Y5Bz4t|hbkDp;T_mA~8gc6sY!^_T(^_e|VtC!^tAqZmlb z*hS#v+OME2dr2DkKW7Ku2!fp4s$Dzj_}kY`$MX3`}gHZk$7Wrfo2h z_pw^l$cO&TI|EhJ8HDWK!%X(&cf(;n2m+I&gT&O)lRs;{^AQ&V%byBivNl$2|L+|V z-S=aYk>h8D$gRDnIoso3UVa$rVRHM3@Zrf36)MH9(U7=aqlIjuy8&j|qqUs4=GRMk zX5{%*EhzkJa(smS`=hc{gHd(snZ6GXzuqf~I};39!jH|#Q#s6C@3-!U8Sw=a9hAa^z|)zt0C;?}|fPBEICt_&(2!5VdoR zBkO)5;s%=ugmo-#Yt?jf^uwW7@to4@7ozKoIh0>k9;@o&O?XU2 zNWW3)-R*Vba@wy5;nC`ObLi`-+v}~8@^`mEq@_74p;H$lBNBX8Rtn@_zddr8YrT6- zN$PNUGX8Rqb65x;bMU!(&gKXyc7;THuk|GAWY?KU%JVPjW6Bh@CL^LQ24I?0q1C^_ zrRY`@z`A`~u@V(|r0Q=M2Tgz=S%3HQk3jEPS4>=zBI}Nyg;n$A!<*fdP%4 zavmLkU@2Rp$;qOyW0hxBUj9i-13{GinTDjcNuIHE(idY%tmX~j14$Xibf=KZ(`lnY zW!?pX&*Q$__czC=)15=(G!}JebGTi0m(TWU zrVYtH<{NXLZs~}BN{qYB=G6@*?2I{!C=}VEuZqnRC!djb7ufmM5pqPZfAoVDX9dVL zrTOK+>D`{}kHYF6Y443Co;f@Gk5qfk@%~`Ra`h|4=fPw^mWn<9*-z6nY=MpJUzvdg z)iyErmX?HTU5C!M?0b~NYqqxrAp$xTt6Sgy_FW(r??d3b(WkJVai3;vN=h-b41}?- z6b*!tFs>er1&loCQSo#=!r>p9%?Z!um2HN2&O1l?lI}Y5A9msof{;zFvNtp=bgX20 zt2bXyuC&JdHS?3xh|$)T@L@Cl@(h_s<`$WuY@TLZjE@*);LTykP+{Y4ZQ6F1T8m31 z<~ee&@rlv>6?7YY#Pv46w6$zfk^qa?&?_g6qvU#&lK2#l+-D<4Ji%&M#cR#rG-4*s z0~f4W_TAk_-dj1o^?_yj9rcB5=UH8qZLNm#Mfx({+-|Vz$?yx~jtaTi`)eA+r4Mxo zLwq8uKeqTp6THyn`^I8PSl_R5my;CU)rZ;~GwS(M^fy{?E!h2A+dfhakVgs+i?KMe zO78Kjm*pI>!^aB0$TsT5g+J0a>IZeY^DauG~99jM~UHt0xlT9nVQ6j+UE_hg?fOUu~P% zj~%Oz=ajZ8tF}bAeKjo}9;4`J?>g&-rD&&S(8!j%_7@I90qp+Y5cgY<(h0AD{iavn#}p*wA||=~qSB z8V-J)Q-w-g8B_{|&_>drU7E&Z$^RT{HZSgjhz!PT+Mi6{J!f#_;={gNg{lH9%s-av zmF}I;a`qzXsg#_7f{?YE@a5DqxsyVu!c!Zj%e!9D&#J0Ef&{At{@3eUgnTy#9q%(H zHNQ>E9p;*YMU~a$3~el5yY`P7-|O8vPhn-e*)9q7uoxfG(?zBW|Kw!s*8?{5j;=`Z z6dk#SxVXU*B9OBOYGXLx1A0RaeXgm#uDI@NJU(@aqbX-9L=Czk6!R3 z`A&RgGcugGmlC16)6L1d(b+-eE`+jbjDN9rRByB19pzX#b5;}P{w=Su=I?)_7NF*V ze*dP}uUc9D&>4N$sglfBfb4(_4N0C}EH362OHvLJEFlaDzmaTbQFWB`+K^~wTziOR z`}0)C-~{GtP#v6mBTQg3^P9u&CtIE&1Jze2=a~E%BZ(nxY2l|BM>B7SbPlcEn5TZ{ z-sDO;hCeA03g7sU8BuXVsWiHXdu{A#t#CUMUEV_#*pw!|_T%8NMtu1_@8Sj#=E*rQ za&Au-G(VGg&?dT8QaE7Cd=|_Pewv<25^*s^hgBNup+1-AA(T)AbVhVqus2K=FI0g9 zPoY5*RRsEay|u+V2=vsn#_gD}bnziJt?BN!cU<8&cJ1$?(TSCpu>`vmq{1lNlVm4) z=+hl9gkHSJrMhvN->AO{r^Dky5hCZJ(7z*H{4>e{&~Qy0ENl7iy;*Ymj(k$*GzuY3 z@qy5-wa_e;Sf@iNK8E*T4{S}8Bf^iYM{96GT$dq0wt75kTV9_T@k=I!5gQzA%VkQJyEWF5j=ItUXdJa{3(6m(_7adGX=!0|KpT z_zGN*>NhDC=@;3{xbA`4q?OMM+Q$ti$?}aWOs=_;@F2#rrn~Z=4Keo-n1@aYATM4v zLza!7DCm|rKk0NGmz%y=H} z{i9g@P7jk%9@#3dU7|%~lrV0|>o->2)53B9*bWI?q`D3ZPm8|rdIz9F}H z`T}?3kRxRjNaAFrp@aN|e9+@U31_6z$B=&I4%AeS?a+dsv#*>ND(~(xV@Y}V_h7#b z%0s6_(IWw_xhwOPTejM-1t(SEYu7?M)oT{za`WR!!ED^uK`M;`w#25bNx?pNbcU<@ zx$du;oJOmyuokdLxq4?lTMlQo=y<-1j#h7%j)l3t&c!v z!6IKOJcU3%NagZpih%yxSY0KB%`-XbOy|4x-_D z_EY!42iT)Y7{urbYmBJIrD~rmt9y+;)+{0l{{EpILkSH+W{jeJb8m_Z-z|g}NZRu1 zUn|yi1$x{kH*FS8^<>MRbFYoxaX40Dr;C%%nP(V1*4{VjPN!*D^IiOu2wHE@wX z80Z;KIGvsO3Avm4OuyD)K)@(OFdf@w1ki^OUgW6r~OJP zAesxuSSb5BWl0AsuTCYUuur2%?TgCGC@)R6RwtQN5cfd;QsmYbXtZu3%QQ4E_!uI& zjtpPUHcB!Aob1QrLK)akWs(Kd>YuDT2mYmX#+DkZe$oj&`u$`%_Pz0t$zXaA)h`YLw8pvsJ7w3e_Dq7AcJplTNF zIyyYBnNo72qOW;*Z8wuyn7c{;!klxMMrE1iak+55-{LZDYY6oRO(aahnWYzDt9S>J z(t0*VikD2imfu+`j8q1x0xpc#19OtZxxAB(^QhLieb862D?FK&{ueVU{Mck?tHdhu!w4Aiq{*)V^;kGwu zdDDd?0_o~?YAuD&__IhKjS@h5m!3B1QkBXwiujUjLvkMEHH0GPIjZ_Kxh_lO2>Pic z?`U02Vg+76=icxSzhJ#|+MpHw!CXZ!k5)N<6Wx{QT12Fhm|P(&;*cnrG&v={pDdMh z7MUQI=%`xfun_Gai(6VQm7P@v5qxISE6SEK`1IR(jMdw$081%H~f9;fu0@Yj-s@RzN&zGP+pH413+Hu z)Kpo%6vQfPlxk!;ad=RDrG*I4VX=g1cp}%rs*evFq!;;~O^M=!Dc+{^yryZVcgS5O zCI$=*==#1e03zINddEtSA>6VC$4KCptP)}=$Yop8^Ce710wN;mzac2gc)X)m>i~)p zbyZ;g2WhaOg=a32jLa+G8FdC~{&spqnQROc&@UJ!%NFYJY?w(@|5mV5(K~HCQ(A)r zE5a>0_#MPhDUge$n-!8~igbD&&A^QVzy0{C;}ya3m=nWG?a;@N%}QuFOM6$M-2UU` z1j9M==09lAuljszyOO|$LH|!Se1yy)@#f-S*By`rq51(*AAEU`sa*INSsi}k$_IZ80gdhx zn6fJha!hdLSb@=e>}3a^esBW>KkL7}5~mIa1fO$}zKQ_<#}c@vY4WTXJ(vsL@qKy| zgh-q44u&=tvLLI&Z=GRqs>!2$P5=axEb_*1<#+(W%3aA_>JJuxAeVkzgCunh$T4hA zmOx23%;Y3&N2nL&UUzw3R0EvxvdS@CHQn?S+rmQ(76*Nd|l$%!GH;A?@um5q*I!|1fPoGTNBH6h8OotRTCh& z)BatI#g8JMZ458&p5P)tIt79hQQ0blh~)v{xKg{US&_GZNb0D)1c<>8;gFze$G9EZ z1Tba(C$~N_r1t;Ed|`D)du%`-)4YF^7S{KpdLqxKzjg716@l9p4>QCTef64|4G z6zMHi_APE9BEW1moQn_*D#OG=;k7Rwi5)*xee^eW_NZYMJ-8db1GBpx_d%No1EMnM z^nrW2-+-l>9s)>c2R!_JP~$(45UwB~df*&I!9pUDy$t7dM~HxcyaMlc5y)W`y(}7+ z08lWm6|tHMi5rgeAZk_uFpeBxN~{1=`isDm7t#M4fgq=-tmpgyolOL0_QL@xv?MlNYvb_H&f^&^avFho_W+d`vmz=F^nDCDwamQ4dkjh8WycB=Qv)P(t-NsYfd~L% zD*&S}KLy1S*NO|gkg4*zor?j}%<%veT(A8LlZ*!8k3hO3G#Q9sf+Wua_79LjX1>sW zgTwZcA$)GL?7{olh=A~T0{(viv`B#qG|EcM$~8#oe)MFL9}SmMen^c$}r>Ip6C00&+nY``{TUV^G;-crK+nT{tj`*>c&?&VSHp-EN}N zAMQNX^R&|AnZZSlLwECrTvhkGAw@G>3Pdz=5XUrS!rX!_(F6Y zwkrxd>#l!s=S%9ZP4^uV;e}0;i6R*gBC^3bb%u9#0%!t6jZ6?VpmRb!QNk%#-MSr< zld*!07l)yz{+pD5uGr|t*B?+z-+eXIOr+w_;QmU$I3%6^RjRl9s&~ma^CoO8-81mz zt>@!+*`VthESC{0T94zeh!+MTp6i>g<&|mkWi$VZp!3%*aw!^br>sot(IN+3Uj;(X z$ct*;{Mp)Hr z>xy4+pG%HRCEN9SLn+?`_Lg7nch>3klJT1_YkErmRomkn2O@#=9LM`(=D~Kq{lzKV zLHvx9rl&G3Pn+CN7Bd>TlLa%7&Wq<#!$X5fhRcRyuaa%p2c44Fn)_XQMYHXio#j&U z$2Ba!Y}_~AeYvThkrcO6ZwuJX4RP1Dx~G;a6eP8Y`rP-AGp?mJM*JEo>nl1q`lev& zw)At44rsLD^sH14lPgt~;iQyp{tb4r73Mtk{!gWF&j57i3-`eS9cyxbi=5)sNG+UJ<-;Go(p>C(wWID`Xxqj8alhjx#^uo+q?oX0+UV6O zehcf($>JBH*c0!kvp7snqZtcG4UxUn4y%YFub5T-5?g;8c6o$i8%J<@p z++VAnHZSc}PzpKEcw0Sxa{Bt~3KtXhWJy_g^E_aAp?&9Gphh<>F<^RkPj(esjmWNZ z@K`jjy()R-Jna)6N7<9^>Hi{t<+P^e0NKDRZl9hw`}Zqt-|S|tNThtZ=Bo3H}|?Exd6z4$fIX>tNuW9r@?|nY6It>g=fd0F*DFLy|3<< z(sIQL%a!efWL=T@eBYiA7qZeetGzr!A5no@?jeQ+eYk`e_mT-G(u0K+R>UgrmfJVO zS07BeL-RJRL9H9%LElW^>C5gXz8Q=Aq)smm0ryz5h5M@^37mkQlUMh_Wo5XN91Q

p#qAOOF^mfaUj0K-`a)q332L z)k*=kTbreMOKgy5yA&((P`(%+GmnNPW}dfxsj8z}Ojo>5eGiIj%K?<@<{a zKSrc&t*u$*g*5t)>dN{|Ys$zQj~&YBP2b!3cNf%2aYa2Bs30rlqU;T9tIp0wd9exi@ena98PIJeqENt$&8N zYHEF+xLcaHNNR1o&8hUeHuJ2eI^AD&vV{Qpu z!7j2(J<6)OQ+XS@*>2YGhud zad9}3639a)J7GKQ&Jd>b8Tal&k>6gnj!8}-Zj+ZZBp9||HB%e4^T_yV>!musyuh=4 zW!vuS%RT<1DX11^#8CDD;>DlZPFu!iE_#m+9Z+yy z1Gu*=gWY5O5<~VBbp9P#=5r`?(yrO!&QxHU_s^@;r=QuufWcuA6D8#sr`OO*1kcmz zTs`+u^Er<8-)UP+=wIB zocp%-Bhz{gRecc4xDA%d`yWOdnn>WaoP7bomn){$z$I~5ay1k`j=5qs%ow}3lHP4jv9mPuNkEg`9``5!#(^h!1iCv#J z9IrZtEWTk1=cR@iPq4#l;AT=C0)8|{pA1n!gWdae%fSP2lz)(&8ctw1*Lvg)i!oZA>GfcM(C{LJCc#wHWfPxX2dSd&5GnC+dc;q4=A;$ z@`Keu-QbH&G5fpCxzRstY78!ezoPG{t)#2SYq__{A$)m%wC3=oq!*4C4eclJyN#69 z8PMU{2wS2)HQyo{9m``W*$(695XhX+*wI@rR$A1r$76SFk7BI%PRiCki8E5{T_8KJXbUmU@kW2xwNCpgO*~p2aPW#YHT=Y-he*Xj&_V!t|LI4Y0X}_SeG0sJOZnkw* zk^4_Vh|zy!BRY*+>I$znz)du}UIxB1Y(baZ_Is(%guEDAHcv7sk^@UJ%C(OSbNiC_ zHh=wKAh7TD$DAVHGdam$9dY{Xd{Di~1+R@tKUYKV7os%HT!o2h>U97#DOa?%2ee*V z$Q;)N4;Gt3{|yu02k^t#QQ&z;0Fr`o-ho3l-eA3$|S@HG!1R#_hhssZOF150Y|=aGMEe%ECf%pqmTQ_*CZIJ)QP>%$pZ~LOzLDpXv^tGN$}p4 zfd_y~ID0n$?3zwKiTTBlzz3QY1v>oO$8)h^Q_S_eg{(d?h8|%pAsdC_8GYxHKJoZp z&jmy{zlgFsG;`fWSCwy!d^9qtPJ(&wAe)?OD{aQ(arXDfn5iK(Iv_MiBO*YlDObQ3 ziVQHizhvpnYRY{*^(uw>F-{LBAJXO}O+ zcrCtN^=*Q&qfsO!H6Yv2cFJ+mhf91L`OSD_My6t;fzPTn^4z4Ex6Gbsz(L$}R_yMk z+tLRo8>%;nkR2WzW*6It6XoMMxTAIS|E{_3P@H+Z_OX#@6r*3=7Ea%#vPVI+<~v^uBHNs`J29J*Xmqp z&3Dc7-ZnJJj!)NwHG`C!B5%huV&aJe@78+2%9})x?`+!#(4Q*yPRsYxnj{2*GdLf3 zrB|xiisF{e?~?I~!&SR+HR0mZ9>TZcU{u$4!-?Ow;L;!6+AZX&y42oq5wq| zxhcCF1_wXA!;&dkYV(&~zAPObSGK3j!??~5EX0T6gLlq^7BT>!cL>Km+vd+|GgobD z{kh&^qZXenx`N5Q-CxdC>-t&n(6>D!SwJ7r<tqvm)9}KmI&Ji9V%JJ+cv^IMxZ44?$Cl zkN3R|7k<0-DBydDUu%zb^Awj}8qe}2QDKt}Z0#%|R+W*IwS}*!AMm|cUj3eG<@$K~ zS?WB+A9Fil>z}@m&R=H~_jtFbXLy`DIAtIdc1N{h40BrglhK+6+2n8|BSY3zknPxj z>?QP*6*66^PU+g__yf6^s)l=<-5>SrxD}Rl*w{vyOq(9v7TKOsjR z2|sC2Z9jbk1@{C;ANfBODJK|GE-8Q1pj6%#6Jk;pOh|s0BjP#!QToMhZ@pB7)WmYP zlw0rV_Q8+sO5qhhY~h)uwMUh{qTF>#f-7X*pM;ei8!<lKNOHm{58KNdQH=gLtM#$I<%|n) z9}0c@R{k1pi0U=A8#XuC5HGlMf^%@1xb8HIfWHYr1wGGq*W~0vT<6-^4IqVFrGsa( z|9%uSJ3#QR^%EfpTHR#0pA;aT;ME^(Y{~H;Myb$6RTsZpjFQ8mu6nh%YBr%YID?Bg>jJxLASv7u0 zWT2?cPm&x`D(JwAq?vr2rd(}_DpguGM9x4N4(<-{wCM!tW87;{SDqthVgkAg``&ld zkFSC2o%b5Gfzju=z6`^`b0^#e#h{>s#fGrU2eqC{bmivf?b?l!+6}P(BA~L>2ToOT zbAvNTULhEBJY%MoE^XM8Atz_Flk-7ciEWbyps?rW3P8CC^54c>x;!?lgnLH`oJc zcq^h@7LVi+-WHQ1srdifI|u0fQM4oIooaOvbkhE>PHdGyCowlRz(AfpU;Uj~zNW`> ze|@0f`UX2Fr3R8+H)p^lFuJiSf_N=;Q!c>oBp!$+|CtL+g6h+v*Eo1vBu|q?R47M4T zpEAyWP?-oVNhtX!LL}FM5ZtZ}Zs)#P2(;}6ZioHMt)X=Q&-w!SkLnE=K`&IGm!O|_ zK`(JYeois5hd9AX;Ifh=p8`%WANY;W=Vj!jm6AZ*4U@ug@QA?Zh^XxFa0%wYRRpm% z3EOk^pjnIF_(U*F)u7pCV_aN)TOy1Z2SHt}FG$ zP{Z`uu0Z?>JEjmgx9(H0;pjI;9F?w1}qLNkc7aU$wF+ZNf1D8mzvrrieL*M zSSz~1lOXjv8{)p99?={5J3t4yxAD{DfAHgmL>i-Bh>(0#37XaVV*i_tfC+l+GBZv6D4!!(dmTn<4A3bAiRAF`8*yu?02nTj5h> zlmtaUMF#ppl9L~R?t3dqA!#+b&Wf=7ky%l^De#pOxheZIut~^gx3eQ^%H{g(Ad<3hRkKi5Zh~o?;0`@pJe^Oh&-8(v~P=|fAFD; zzdF^(bIRSaC-AUedA(*OUE9nbm4F5n=)-^n#e5!@pl4{6@WcK0=LhAE>uOZc_56F3 zxVa>ddU7gTwpnf1>3pQXP#dFjk?iT>2l>ThhZ0S|Rr1A8J5=Nj>EePzX$awf9bl-I zbdTLXOa}1+uuOC>`445v-u_$PN8649Hj1kYvSVc>#RMeE4+m6E*!*PKj{JST2ONY_}o1pmd06di2>H;D zVEjTG-VoKOP*MU8+!(~8>-F-V^?u~da^!`JO~P7cuZKN8pQ8+X2KdFLigxB#n{uTP zfZrtaM4h51MO>p{uZ^+aF56zo(B^Qpf4y!7<0O(YOQX4q7n}r^ptdFgJQpBIWYqZ^ zI@4$3(_Y!*FFvRZQ3mXW&W@%na1^UiLu*63EMsN9j2Jl-8peAOp zdj8nc73x(6hazfddqoFFB*6g*gqlKt>K$-q3#O-RRCK%ih@Z+cr`MZu17P_#{6WRe z&|W)h>18p!?GYfy6)eU|gJ;P9<1-|ok zOTK~?oB4Y)881Osd|+p_T6!SX8?{}xQ!uP&%zvWqHC{Vc#p<^koGcfJWB|o&WEWhr zHoZ)u0q0(*qY39iz+jL%A^NSQ%1`S=BhwY|LgrL2@=z6fxG^eV#DaY9-1vGXo^+-2 z0N73`*cj0EB+K|#p*Jh~hMLI*nCe&*mOEPuTyH%KS8)Wn<}$ButJ z&eF>N3XD`&25=Oc(5{OQko~Wg67;LwUA95db~dO^r$r5@3J~g9a4JhS8x2o*^0+%5 z&rhy%LONxiX2_a2B2Sl}yj)@zO?1`>$8F-uZxz8a!9xc!NQ3oPQ9iIt=hf}}RZ2_* zR)hP*4GsnkjnN7XuG8Kt zy3?Qn-xdRt!kDu-W?xvDdhawG-wk7OecU6PTTCG~_F2ej67e}Y5>op$OF^g75ru@* zZGKC}oG*(v<{Ro))c^*ArOK&CMfaJEX*pE z_FxI}vxE_3L1n^om3`+EY;xZZkZ_0{ztnSmiVix)`{j#^1N;>%Pngo<)fPqBABA$e z&6LcivqQ={S(HNl?ABI*J{ev`K3aIphb!Ow?8KbA554-J(h*PrdC|>1wzZ~=`{s=Y zy;9;(<*LC^y`M*%P5Gw2$CGXvFB?DsKZlWq*@c!klWsL9>#kjs71$s=*ex_|?BA%I z6|(M5ia^4j43$=j9mYld0=&>`jIlR8we$y@to9c>%XfJ)HPCJNxCHCuu!6@I88O6o zc6f97S&mhIlmP42p?r9;3=0&Rl?du-xy-nxUD}z4m*8eB#csduSQTkW%+@}z+8_0njj8z<} zp(`#)I(gp~zu3(wY_snJasW|Y{fp@|&*^;Z`HCDdUBoZD#NEu$$bsOysXBmr;{!u; zCuf=+ZciBl39Xwl^0o5{>b7fnPtvkr%zI-Fr47xUvxe$w;h}yl+K&p4`j5V)GbwtVF2S==iy|MfMs6?DeDb1RC50CcuD(-6u=-P|RkE)20rsP5zd5(P zGV#YTS3_mu&!CNpwJ#}phoVB|s8=`uUYtdm8L+7HPwpRCn;ZI5sy>Ql5kr-H2U-Bz;n8HICv<+`?@+aZ)!*g@a&qF z1OiW059fGTrH2C-jjo>teWZMNntegY49(DLP(LvWthnV-`>>>)1kM%O1&DL>{N$r? zD}cjrXB-X<+#k}`7%t*rS20UsEY{yKO7#edBnYn@lt(+&^#}aStSxoD>sa1;BQkM= zlY&<8t0|8krqB#?*GPaKiapGeQ|$U_Fw;=w#5gblSk4T_$5j(fkt1qEa3yz&vjk;W zzxY`sN|RM>zEPfl>M`}KP8_z8g_2-^U_I^b0INrn9g9Cf&bBq$RvC8p7W8&?(2UPU zaZ6|NN?}Ru6U;um@yc@7lr{>*+O)Bl&mj?gE6*T47SKu6*LG>TP5t&-E%Tlij7|aO zX^8V=N4@{?lzt~as*u%nJr_-68?8=zDf|;wll*CEAAZjXnT06f5IcXzn0W2Sa|<8> zU)iQ%RL_f6NffVLYD7+Rk2j`n12&~`GxvsUm$QHh{}Q`rz6^NJN$BWsUPSV+=$QOP zLVJD|{?+^PYmu3uCLQ_8{!`?65f5SM)Q4vAQcYYV$fB)MJES$upg%kNUG({gz zcw=WdM#WX8mddf{4Pv=houir8cAs)kk>xOjn~UL**arLLMFi|cnmx*6XdWaU?&Hbu zbKNX!#5xxjukBucx~~%_E2-((xQ|+av7+CpPPyjo5r@?9yu{d%VY(Tmu0?b@EXAkK)t+zD83iWmyN*Az8bZCWZ@;(<7WlXNCb?s^ z6}76%E?f)Lj#~c?Z9f$+Wfb1DOtD|$@ni(3xIIwm>NH}vfo^if&0kIIHjpqH;wBZw&%j^<& z6rVReE|@rIFz0+vm(|5iZ?r5HxKu|pLD*Gh1~Jwe*iSBwIYK-^kb_)qjOOvu2;ddH zlB#Qw(xGn`87OPAHy@JoUw1~lxicCPdBt#>gb^#N(XjEdz;Ga>tH`io2yE`?i_sfq z7e;BYX43vwQUbrIpo?hkn%+Bp=a-9S7_-OCQN(LDY0mdx&wook>CtDS&mU%8!TSgb zuW3jN+FLijCT)s)YpWC~0KEcnonbuvgYVtZVB5T5vL2U1NN1=YK%f;cAkWf+_pD6o zmzg3l-6=f@#(j4c%~*5M3);wBtYwZtukqz`2mQV&_%kc>Pc7CT;~Bg3f&9&tbxXi>llJyzV{QTE_W7J|M${| z)e#?4UCCMZReuBerEyt$_Xpqg$6j$8CW|Zh&0$Q#@84%TKcpLaw7sH*Sq-1%2!66n z&1n`6_dV>Ot|4aiyLY0vw~l-ljBV2viTrQ&zrpIEk(C^*57Cg~a-yYau6^YgoDOH! zP8s#(z6{nP&b%v&wXwT%7O=STP8r?B>nbRPm?zDq5t#++$iEyW{PjLVD?x9^bgVicY`m-6V7tEfn|cw z>(U#Hb%Zl8Qk!2GgGETmoWcwh1dVby{ZWe4S3|@|%YpFR31ySBj4vvyN*9b_VPDc@Q*&s zl_7!H33VTv1kKK$Nd@hRUndDwZAmbK-e&EkF+CJuBxe2TYV>FZx2m;x%Sa6`nSiW@ zP0y)dbd^2N=hpS!Pr^L+TRG_jJ=cXC$16YcOQ3EA=l(%5q6$~72;f<~Kd@Z@uDj!4 zwW@!I;3*MY>&n+K$$oX%S|-G>nlU8Ooh!RV22A%FyRC<19>5POYN?@;ZiUkkvs%SX z4g|FzrJuS{|M0UCfGiXS2CiI!Wfb}x2$4ne%yxi0#76f0P60t9nQ_=SbXH3vx54`_ zTxo(8fC6>#g>cswOEvm;(O)zqeVE!=xWMDj$Y+=URqmn?i@dTSa_dHg1yO5=;V! z4`F7h^iDqZ60}N9W#*WT1|Uz0mhdwIAAqS1xQ%R*;?4l{1gV?4$MGBg5*U-WrX%qJ z6T4TrdxbGr8cdS+`hXZ-0BGX8L@ryIjSehGkRp)?96Tcs11E5B38KKYs1E~d0Kf$q zu@^5}P>>6lA{Q-&M>xR{+z@hS02bbZ$ioEHpM(M^kPDy##Q>meBW#`(0=cEA{m|`& ztwHeo2`}il`Y4JicoH~obDEnQ=NEH=dH`j48T$~$$Oixk&tKZnybnn*;wYYMy#V+b#)Ujo@c5T*a01^G`^-0TmH8U(ZR?*%w0$omqlWu-f) zs(cr;14chHgB!&f%yg-8YB&?jjs~|6=r_F)_oghKHwhRAC&YVofcyWm5nOf?9qD4k z@aTsxFo#v4>&)aYEhk0Kn+@ch$h00S(PX|I0d>^EnnO zMc#pon1F6)1`mhrInb}g^v4_2^8b$VU~d0{q;KYd>8%Hf_PV%%gM)hjo;dhY38t6) z(k!n1uP}@MWHv}3Z7OzDxCFJALeWzF#*^Br{E#f_aN`EaN7GpV_~~*gMcww7TVuoX z2EMc(pKDSGZq-ZZ+UTW$){@fZon=#nHoDt#74y0QYOpd4hT|8{pNFdgB( z_jFl+hkM&U(D(%)@jJ@QtBOs)?Lx?ZY-39RFm+}c^?u3C=fyw^wf5o8rAal+3}1mU z4JF@ecLq)|gR(g^!9xhP14A2mE%EQgdIyAq z7rwky&~K-rR|MZ6eyr)QcdIHEOn9VfNVN`5L=zE2#0d#5*}M$gH9IS;rzu7V&roB zNDOdVd-6X|U~vx5O&fN&MdxZGhloFL4~b>1TrseGH@>%cp;`D6={B__bZ|g9qkD|O zdWkN@NjZ!dZW?#x=MoiXeQ7wKFR)^*!p>QFqqugd#J+XsWiIk`ogCH7 zA2>MUluXTU92l!GRAPe_O+K0J9_`5w=c}@i}=!Ra>|Grqg_!NG-md%iZ9g*qa+m( zXDT;2OxSvJ#$OUsi=p6R?|>6GwZfkW_v(S+6+bCYAH3{ozls66yw1t#N@kNe?QhSJ z-qE{u8D2}DII@i7|W^*G_{Sz3zojvFrPEF%R+&4BN8p#CB zD%=-y183h_#(jFBUEQACg~SkP&4$yHsqfLZmOH-RX`ebX7xC=Yl=^y{9w?oZsI5qB zb`doo?hu~X6{+7Aqv@Ic$N#k=lB{8Jw#Q-vAq{L5oIV#{R$%eizg$QKeN^QX6Tl4z zzZ3{6?@%#|#_8QkEvo+$W4}f_`s@IC`b5ot9lOc^e*yD;XqI~BHQZVwmElu9zM9vr zlonU|cm{S-)qg~OZ2XYfcT~!BN(Mel)4&W;{l7PKL14`uLQ)u!Ld3BvkoB}Y)(Elb z7c@+Q-6+5jj;vWXHy}p&p|HvhgD@ zlp)oJ4DJ}%OlV($uYQH6!HXN=rD;v{=U){P?w3d4#uL^}ipV=6_Kne56paT9*~7=! zjR9Lpzy8~Z4oPP@?~Gd1qD*ME@SGSbeW|z37qb>qJ7Zc!}NBc%rFo=RZM(; zKjX@@I%QWFMh0T4k%UhbJM}-?sQIe)49FqJeCLqphcm;&Kfa5R1PDrwBoSXDprefF zrkVhXxvWVk0V|0;?*t#B17o<@Jl;1Q$UE*eo6Pu?R%~1n5!N-dNBI-!c)di&KX_{b z&8RFMeG-6pJR@4|NoA2muPdAAR{- zbYYDVqIZFOoWZf~vl%#?ZGVaqr{qZs0P(`alxP&dU%B`qVlEoMBA{w3eHbNwnKR+< zU$GC`D%*=Fr#1KMl;$i;<)%yYChV(W18X)Rk%LQCu)pCH%WKCV_O}in#&z|Q1f;qw zG{g?l&7^BxdyYJ{sH#1iY#DZ-oTPEzFH12U$t$f5eOmdWiH?AdIP%wfA&{~N?Uk2b z7BE@dyc_f_=urII$wI*S@ab zOz(^=YBTXi`dDpVC@Gu9HyHZ;*tcKskI{COR0+WQkq4HYJbU30Jut}P+vPDJPlV5_ zN*{MgJPAPTE!?>l>de#0>;`A3EWlPo7@4;ekE3sB?PUv&f{Hu|RU=oB?g z>r|2#>r(?M1tUL&F-S$qD`BrDia@SY#8dnURJElG+%&K=LNxwgGunY zrhlL;7o;qzB(l29hPf7VDqp|548WJ(hDTH1f}g9R;o%UKoqdR|Y|g*EK|T zq_|zc1`=tbwwJ;7xwAb%uLtDZ-6;cEmndD`x}viih~J9@sugWul>k`EZz13~R)pDg zr(j#Z?~Emt z@7#O;es8=t-eC;b>sx!pT(j1!kGD^hWiin2qaz?7V8}g|QbRyMnnpmlg^qd~^t7sR z#UUWvm9v(Vd?F_)N%_Rt(bC$^0s-Oi+c+&0ZS@|)G(9yc2&$Br{JIi$G_IIDD(lNb z>NoO_kzV1NNjxdf3PUe3kSfb8qzom)E;rF4?A*~Myu-u{Gi@trLPK<&(Q4s4wOkz% zwYuV8o3FP(_*i?8GfJv~_flUjjfH+XorjGvQF;>s`9*UJgCm7KN`YTh^*cg*z}4;w zRO#h|b6ZULV*iNisZ2Wj9%KYcRqQ;pl|cPBSV%AV(6TFp5Z>ETRA=W{AQBm|w*p#-q=2!@wt3+1iNVUw!Qa@6<=l*d8S4169H z*gxvA=fp_r=ne9Rl+VQ(mK>9;gOYswzEWy-j%sxyn$~lNh+bYRc3rd8^M?lT6q!67 ziVf~~VD{R}`@Jx~;@b(TD@wVrH?Oow5b%laoNvW_NX$olt2L9wi^NZ~dY6NWoM!0l z%F)3?2|uxW9j~$T6r8Es&+XC2I>qHlK8RK+rgVh`X=D9hiWcIiSzLW(=iL9ffp5%0 zUs?1zg&lSvo`0KT{Fa4T> zut<;gdk5C~o8_f;6jPi~B&4V1OXktEBAT5`FGhu~?@j2>D<|PiI7Xg_IS0ly5qCK4 zUrYzopL#uaDLCAtwl>sA=~ZbG3ZuB6vyZZPryY?9fbJA`4AQno8?SkFFYYiIl zmssYMx8A&z6y@TeDR-1Yn$v!QaP{gjWI5>I;#@|Wl49Q|q)c8}s5@U5C2B!5SEJ{) z3e_O82_?#v)*++ZZQ;iVDwH%=A+kQ&$Q-Xpwd0nq(+jUqw!69EJu{5nx!oBZy>_O9h#tzGPO4W%!W0+5*WsAkwb7pxKawdLx zDtG=+w2olhwe!-+@e^j86TJfh>2snwOq7#g=0=J5x!qb$<|3+D|FCZ%%;S!-PMIHL zP4GoS2Zj^xyWcMfV(Qhu{y-5|Y8`aCiWza&ApKnOIJgXp#O5t)JErkkSz z$Ms)i+rRIoV34%vjw3ws*Zvr_Qus)J2K!q~5azCkukqdf0E~BC#C3@5-%yamAH0D$ zQKBinTCi&OWxJ)`rpSSXAAnm%T7c4(<--TY)tBWD*$Cg#nQeTyn;cGHmZMFl8OGBN^TDv~YAB0* zgk$xMWgc6?aFopXpjV^PDDC@-TB_*)EK3jhH?Q9}OMv zozTs!R)+;ny4b(3iF5y0^Ta#gSaVx_Hj?<>my|XO)O!cSbN8(7kqJnV*^;FJ#(X>I zkgA#LU|?@R1cgAY8weU?-R2VSjfM7@t#TwhJmGTW6vg1W|CN~jevPO0c3I(<1sKNE z!5H2K0c?AuG2=uoJ%{2`dU2~ntKoGEqJyy&wmrv$aaJ2~P|0_JFU_y-ye7h06usb+ z93LL!`1<_ycg-(^U&v|9vW#i+M+CCeWemP5nu#v4EKPjvdMBuKk6wh{G-iUnOXfwTg&HCyzo}q@%4PVdFFb2L(S%xa*47llKvd;0& zxd&|%2#ya{_zdEE+HJ26D8Uy)u_td83E9iAfsb6LLQ!Bto(4d6z`j zi>?G3RvG$8{fGPW-Hys7%KXZ6DLWCPp&Vy% zVa;LP7Fm7vTCZW+Q^6$zB}UMBsJTP6L+TFUPW}!I>R9eJt-3|GbwCcm@{i;vS0bkn zh6)`Bop@iZtbB{xt{#J(-h(~Nd^q=2=$Q9l)!T8;;l$)L`^@gx`h@3b)H!vD<>6*F=88HCt`8HY`|W?N&r@1e;{X|O<+Ti zL68+B9hK_UtKcHk`cAGNXr2>IJ5NrAbu#k4jDIox())!xcrLiFoun(%OtP!wzBhY2 zrv$Dl1sb(ibRqSwtd6YGTLE09cM+i_Z~X3aFV8=CvK}UV#D&w8NE>Yxb4fSkQTIPo8VcYMy(!Z$pGABE;h)VS? zinXGU}M8>?HKW?YWmTfKu?5omnb_B{KRQ9&(<9sA6cTZp-=_fy4* z4V#nA&D7U|2@@ZALX!p!7#mBrFsGqUU{4w?uv}Dn49m32Pa=Ku&mDHK<`#Wk`hGnA zxYBkMaxWwl7S*uf20cnV+QyK>un&>M;^TY4LoV>c(_A*jTD?;hn$;Jf8p---(!1%K z=MdV)+maY!1`0kEn-*`I`FvPjmP&0JnxM;--SCY1+M~6I^b?Uq*S3T9?_pbEM+x(6 z6V-J!q-PUHQRy3uLg-0kV7i-ioO$ohibsUDZ3;crOn`Ia0PTH>4w zFKd1t9|-b$7+%I-&#g7O?BeVYEKW5Uoqhf(zOq}>x@+xWD|*K9&7?1DTGoT&4tc95 zwf}yOS)2Oo*r*l#P(iw&sN?3{&8GdXU!nqrkBxkGn9kH;Lr3iPhU6E0yQT3vPEfK^ z^U@ba>|R;dmR))YwFAPc!dMF{E$0_4zk;qp3L>{6*F@<2UL1RE5{)KQl-3xLvhCGJ(6qm3te&w+OY+ma|k+LSO=IR0Ig(eFS9CLIl4eh$MfvWe^`A z+`8#ULO=+yMu7YoqYOUbKT+TpKIix6t@p1HP{0!|`1SmV^w(&l>5sSmYTtr;Itb$G zl5%q3Q{Bwj!otDjnWO7we(Y<|f%g2djtc?;Asze|QBIBaCs=>dT0`4aTS-yC%+a3B z#N5%;g3Z(ZIeZ-iAx{C&w6}0Iq4c!3b8r#x6sEozApqL&Zgy(Qn<1{Y!qnPIPbeiF zoh>MN**MrZs725zDJg}V%`FAgq-6d~2T#J(&s<%f3$U|$czCdRaIrZ$Td_an=jUhV z;AH3IWCbHwUA!DzO*~m0TxfnT^4B_27A|Ja*3Vt79UUm)>zbH4y15EdQ^Pm<`{y^D z7M|As*~!7>&#}M(+2P-?KV;)z|9fpPRS4cI@Wk5F!cIrZ+8*Ex>>%=`neoH}P^2wxToK*JX zJI&Vzimi#OgMue5eW~?aF1sf_BJ(;!{0F`}L%ZHX=l%Qyp^7-@5K06@R51i3?3V}- zN^H~sbX0dr(tOD8=igruB0`eck^VUliT0&23TjNjKj*$QJ|Kgy@z-)^7p~uc%npv2#S5sK>p|QkmTKeo&xeOlKw@~znt_x#0_&paJa^PF3KiX7cOEw zRKqz6L>r5({>}o5DpYVO6$4U=kZDAKT0=n)0c15;2BD3u>diqD_RE(*6-b_uQ%+L_ z?akip9tFZ$0VGACMzDTYCphyAOl1SmkA&__*Due)SJh3t}GI9YFMAYRs zKsVw(g9y@=Y1~0X?MMQTu%E;H;MlU@*im9Me_>$~JZgu83<6y7Q-d=??gKbj;D;-x ze`(w$AmoaPgx!q;&Okf-?~(qc(*LiFw=O}nmEMxVsNko|Qh0SHtC{4lS*lHUU1CV1 zCT_l@WZO%BWUB!Enye5+s274CuwHO!_T6#+OLnPwqp(NpECx(^3TJFz53A{c8A^EB z+3@p_ucOxkf;8JIUL@>HOq`0X@tM`O#Y{Ex`ENB!o1GF1_0AV{Pgl2xawLM$M@<)b z)iN?qCDf4Ufc+F2(+lMP6u20K_GK_)Hfj;bk!nb`ug$3vbrR)Y4`frzm(Rg1 zjXXyVvto<2br;psSZ2~JcyNmW%B;ujh_6#?)Z`G`B=C#4@bWclZFB&|Jf4nGg|+8# z*J3X#iouqK)AtWBR&jUCSm&i^F2|Vsti|$@1*p&N3*oYsK{d@b3p#AEG6jSVYgDn1 zlyKb^Ox(tnYCQKVd8Z{P!O`Xc`>r-0-wKWQEt$$gl}KE z%!;_Jg-4-{O;?Y0jqj1>m4U_8dsF$i0+Q~iU^66=&Pfr!U%#YgT2CHEUw!I5^5~x< z58S3}2O9dx8@M9Nj4&=G+6s+hmV*}a#bDTK#1Qyj(e?QoFcn@I>b$#Plig*t3a@LL z-lHY^aDIyAJmyElZ_wh}Zl5Vip2-B%`)P(j$@>jQKN~W;`s)(st94s-_6J=Hi~cML zbC@ksgqIwP4ULN(g4sO|a|v0w7j?*OgOQK+@qzw!2M}!i%4sHPGi09**L7sRwNNh@ zJdrZgl5j#!%uXS)_o8=?d4Y^tui7TbnZ;UgvE@8Cj=XY7ys-!Kd>hyL1ZPX*qR;Pgy^b2|RM%76hL zg6{!obPc>5)2dv4jkzYMI=#Df--(WP$klv4!k@oIk$*K+y_vy|1;voc?reEsTB7ehYR)llihYg!kP zOTz-q#o?sAY`>V>nT+fUnU^|8Lj|#4p7MTTnSf;<544oL&;KI)qM`jzb?(&n$~3># zUefxRrPkg;vRb{B>_HfjpP<&N-*M1{miO!Uy;@;|+yqh{H><0u%H=i3HJ#I+$;k;% zVD^>4yIgYyW$AJ!(4mXPq-*ozyv3vjzxPSWQ-l_Idlh{)Uu@eB`S<7Mr8Rq_a&hcU zemrrhJaVci9iT_iGkRoTIr_dy+NyHTyIgari({f% zu;ED_f9rVveV2nP-xQbT^d|ZH)iZ-LrRIk2N@tD8gI{7zZ?&-@)3OC1K=#w%ia9N7@Zg8VEcAMUKjE+HNFa7pQe=dq31@q`h}zOo8R0=$NHI6y@Htbe;m;$F||pl z?zs`A9%ZjJ`0$~*tr_mPF3znGBsDaWaKzxV!7MXUN?{wK|uvQV69qoSom zOB|iUOCQ)uf1tJzfB64(t;oNu05{UQ>W zilw?t478#hu&QQo6Vf4w<6P3+R2ki^_@36y-g7EA6lNY?pky8d(Z z$YvCD<=yv7^B@}%0a<9EfmPoNwVv*S(n>;swPq3lc8}mtg7BY&L@ejJseYx4wjPg{ z43cL;I#kMzmWR{3a#1VvWaiv=9xSSyjoznoW7@al-W}FGYx2xmTW^5oh_XkcDAyZu zr#ExcrLOC^SQv3hOcv>;mfw%-O*=V?i$~c?a!x%@tl6!r)F{+35b?HF>*Z-U@K?DT zI~h`)kasRjxhr(&Tz;@~OThn^(kjXugLdpK$@8=avRFbB4(+|k{vXeeCh@}t#nZzJ z85SJ}a089cKR*n?w)1Br1*Rl=7@QBAaJJqf>&e>f@?Y6lX`*0sO0&;XGFj`o(jFa4 zvd1N=x0W9t88P(?96QjFIOr?6n(xnfv_w5+))ZKC8$WoJJm`E34csQ>CQ+KJj=H~! z%%{XR^5P;iVsg{1`u=!aDT5yi)4BTy&X!*()PYqyvx^?n%P==Q-7dV$R`;y|Ct8hf zcto15VAAAXB=JZZ?^RZ;syTrjfxU;0wVK}T>g(265&J4RH{0V-rOl}dHKN0=Fk!mX z1sKD0Ith+nS=WnzDGDssV`%67;)&n@OXbtA@-gLelbr@7X%{bb9(;=TeP98ZBg*m2U|DL0LlF=E-r zQ*&2C`D`R@lRCEK>wZB`6WCs6=QHXy1^RBMH5ZR#!?I5kuBuJXeRcKxiHBP~FHZY* zH5>Zf(uM5|GV>XRut*(z{pUp=9EL1>J(_G4$`>V!sM|7coNc<6DmxJA5TXPZ7CNxp z87bq|a3O-t#)V?mR|m-_t&~Q@PSs(#j{5tX#m6O0!dn(zs}(3ImqBcglwA$2J$hfs z&(K&w=_^G~2OE^NRvTU0w+Fec4cjJ1Chqi8(}jEJh@M?^GE?+$&?GV`y3cfGC#PKf zv`~&H$u+vm@9=Rf!qDTAC{ml+W-%*$;aDEmZs1yEcg9vt(`TFl!^5LxR6cw}pqkw( zob+R|GO^1*7U$m1$e>?kv-^84@{M%r7N+SFH!|}a&h=oh>tTZJZ2Ho>kFdZ;c^Hkf z7KB6=`)9)s_uH#!CQrJ!QZv0FdULAQA{)mp7U^jr>m^UCSTEZ+kLqicnjC7e`Z9=; zRhOBkBB^a7n&oZtxFV|{&=wAowi?**5bRiF=oI!!uk8BDH&K87kakV&(og^RtMM92 zto&)Ahmctgme(53N$y9EFx1L7kBn$}fxHI-gD3?51;o4AnAtS$$EHk~hKA+`BBbV= z0y$V+M?Z0MoDPy+*s7&bGc0j%{V*+$lr|5udDuJIWt(SvkdSO*qT>3RjHYYRGq-x1 zU_s&QBGcT?$f&^D)l{mi!A^F@dSA>f(K{YS*B4qOa5Bdwq#=z^u9(dJtxu~wSq08~JG~{tE{)5Sll+B8*2rVbt_rFPk%Z0L?lIKPGfy*h88`}eweYNr zF;81XHb~5p)?4-R_zBh%ZZ&(8Qs>QAU#;l1ip+Fj+a-52sK}J5dzAqn5T730{@Haj z)A@m^Xq7G5zx-E`rA{uUC`7MI?uwd0pVdUjFJ66#A-0-$z9v??URA08$lX9*Os?Jb zq>uIOai8Zg|GBz9ss8ZC%4S=%51}6#W5*4xXGhNH=TD1>dC0Aw7Spr_S(WPT=wq#; z?!r#ql@D&TvU&TK9hrV~`CJ`soh0I0ui@YQ%qeoU!NG&q4eeLqY{~gZCd$)aPcN$L z(&fzDU>+*j{ZXa{hpqlzR^cP#*aHJ4_M^`^5nc1! z%X;QlOqd$6yXr_@S)+h!2%|n=ucgh!vumyynq+uX>6YT^?KqMsGM|%uEI$`9XuLm- z>>>Dc@dTzYh+6H2vN{^-YEj%1GG+03+xhIv zrFrd=fNtZC)u@p3K=q);j>s{3Ou}Wb%Ufkx&u-~1Jw`getkV}JY!E$lleAVF-^HUY ziIB9A9>g^=+Z97WvST~S>3lKlEe-N5c=SRJ9!l6jJ$}(P89f+gST`d7eb{>)~>t9d{}<2&f~!W}HrM zy~q&q+-y+w++J!9GjjHOb$2>HFOmIRs}Po`W_1_{?7=13v6bU5XF6`NYerUgTlT96Vt#?q;rgSicS}X0!RG*&Yr=T9s`d`Q3H1AgJ zcR<4npY2*&`iu}e9G0>!1RCezivM@6gdxu~YMkj=D#NVKMc1RINc9IgfeAykw6;5W zM_Y%`z}msGw9E)yvB22>;SLSgLFW-Bu3p&@vZ|7L`C9*Ef=Dwvl%Fp#+%>RRVH~DE z;XYDdG8RrgH#MCeH&DGM%-7wM;Bm)2mxAH=@+?1WY&|S#Hu8G1sE@98sIbG3<7_ro z+S>VHVDYw|OG$EqZP(&)>_xSCB9soMzQ(reHxxWtqL3H6gzAotnY*FT*+vd@_(!yo9ds2NolBaP>_FRsQ+i5)H1 zJ{(Tl4vzc2P}Zs^J>284GD+4)tAUnISZ;QCghcXjg$SkYtCg_5a%qY7zoarDg3QsE z@5BI?;=k~Y`-?WsWu_^nnd5QKv-fio>f})vwsz*yQd$h-$WN-)%NzZ4Md?!A%3`m3 z`PwGC$bt1;PSiKkQzYWGGp3c76RljvqqMXiFPw7T0QK_gt$H!|R?W_wX4?ITE3dyy zP08=nq{X*8>HP?K>QJ@uTeO4&*>G>B<-{jSwdCM6>ea7kO`QnQ)8e}@oexnK&J%XEF268c%bpq5UN4AQ0Pgc- z9D$qkV@Cs~iUfQAP_CzG-rqCyQEQaHd}21*ta`Jszs~Q`9c#7VjESZ5v&qhQ)nV#~ z0q5l>#Jp~kgtTRSy=Nq-?itc4YrxFA0PeS+2GXO~#t8tYGlF`r*|@V#Cz*`c7k||? znHus|hFw{=H0s#NXpy=mlp0-Yr;+@OsuH#Kw-vEEsrSLRGkEI!jm;{%=~b=PNlU=JdusUY#D4zB{PP(}@2`1`+&@n0*esb4nnpVjP5}5cti&T<<;L zykOMKI}tGLHEf;PmkE;(=G*qpVEOQ{i$AZ@fZJ|Hn|IM&;dHKlrD^>iXqegASXEEX9Ay0Ke&vWUUYy>LNaVAJhSi>Jodqe&R#_=u(p%)>T|ON1 zeY{zax!}rR@F{mjq*&T0KE-0kaUr9+`lB)$bRcXD)!pz_bO?wPQ~;0P3XKE0jq1LE ztY`Ip*!onHn0O@9h0^F~>afe-acNdm&N3YDy&?(iZDO;`8`WEp|V94ik>(~1PACXNa$X?1f(_<_* zs8H++#(8vMZ#CUv#DXBA#s(n-8el%lsm~ckKL0-E7_IWP$tq)iFlf8W@ki8}MgIis zdTU|lm+wlINrHBQjl)jk7o%vtU2k!}6#>QaP=-R*=Kc` zco2(BI6t2{33mj9`zCFY1Z>~d*3DFl6#0IQ)*aBA)L%Uz_kBvJn7&;sziFp4^zOY! zoNy!AI!jR&QJhwpV5-wA*XHf(6!+0^V_0Nt=|EW4ZsKmD-wXe$^~e@wD{_;oicz}l zJ2?UZqV3BzjavERuaBBvCehn3=a$OmW-mNS?XARs2o53jE`ij<3W)G0+|lddBWN&A zQ2gYhRdnW^)6ZvG)B9$2b)e=wh*A=ImzVqSFr2d~<>~xt)wn+(SW;D-eNnsfE$ z)MC{qzg~wdtUNW0l$&aRZmRXkdS9^arbBb0om#8=^4_@Xz01O0&KU2KU8Het-vVfv zVdzgQ({j5#waU>|(-`uK-m|tRecAKtk=YWAKzm6gt-V5kaDq$**GSI~(!f+?AqSw& za1{{RlW;!w_g4KE*AQs#>p=ReJ*ENY~XqW zB$FwP(T<0ml&ufYqPdtui;6)uzP~jGT<`%(LR8q!G*7+ZKQPj5}wuYpob`dt`Vbbo}ipIq}~F`C>Yd_uyuSo=m(+uK(bK}<65ZuoqWrJV6h%Z zXlSS!TfvhIiCz&w>}~=?f~`KbMi#As5lna@S{vk8%y4cCAO?IxZQAc;c0QWW(j(9; z(pKFZ&!@qoS41sAwE3$DhLQ#-L>h8s2}&L+c zKV>cfKY_{ky*$Sfbvfmw_u;HVimF{QB>i`#Ou#(Y)#Xc~CS?>Jl3&)8`LbuAhR|0dL_sAI@ySL z9sXcE7SP;)Nl_mX;EDU7)Ob$ipobxSI{B{?QDS2Qf>1_j+zNPv^FhS7;c$IKQn$vA zkw!M+o%+=Joj)M#f*^($g(-O%HfRLjb4w$>LC6mYPzRtuyN>eTD}pS5Ntb|gFF}$L z4Pb><>aEBP3dLH$op-=fA*3Y^PJBn;LP+Rl11UwY&LaeFtN;!`bdx7J;O>N@TxEdSnb zUV1`}2x*j((nknb1{Zk@5!Z{mH)ubVL}(jK*WRbaeh&7*LW(wO%`L2KudN+g6xA*v(ymrmFS!0DIdbYc@-KV@$nF}Mk{~F z|H0-kc>MTQjRIl{>QExYvDbfBzTvX~^cYJH1+8%bB&TQfRf5j?SwNoVaWsn#@ zK+np{udIKB?!T$2#2KV?_lG@mGloW_Zc)4?MpUlumpQQxHgsu+Uz|zd08^Du71}X~2vSl;<^;{TLYg%{)zD_FgDT zbHE?pNe%5c8b0cSb2*zV(W7=G19*0S4GZ3E(CrAo@asy*V}1e}0k_k-fCK$sG?poz>Wn0R)0n0h!6axh(;#-*lH5>*+dZNL=6kd2!Q^O$SEFpP@ZF zKUwroxV}1PM--4JNG;mTH>`=X6s+D#+$UVM0{esM5_=KLJLXrKVMY zrI`2aaX)5Y>W3#e+YbU$2{9PpH0*#U=gzUE=foj`x>qaqAbQAN|7$+PxQl`C5MID2 zrUM|ipwl#-qM7cw`zSEAzpmap+_=%Y3t031XS0LYnrPW`#S%zI#ocgc-Ys5QyA=imo>FAa=a%_ipsJ&531_bqGeHcW_F zb{yq&^p89zIQ%+5iYnU^uKD0IAO3IJd`<#y%m3o>Spn@LYN~_1bNJxAi)HNaVafzs2|bSS?4Mz{+T*6%ek%c|{(UK2fL@c9q4P zIa#cyRkP^lJ!6?{pAAM03j2}#Rl0@!_%=Wyu~TSKLNM*Lw6t^uSg#v!g5zGxP6KRt zt8ysfT{HR-jx6c-DpC7c?itfC)|5*{5+}SZ%Yh^vg<9M6xcmxMTVmlCRfjv!xd!{n z0p1uD(Mt{9Y2%;@&6uO3NRhKo`tCCp%ED)opWL@+2d^qD`poC-Yu%17d8-ydBbD^U zdWQ3eG|{ODO5nC^gxL)N#Q_0jQmA1TRA|S1i#{}Dg6^)`yA^$GoF?rME72*EQ`%{! z7=Ycp;4+Xz+RV?(?T`#^lnlWPcx7>Qak@XQuF?~)=fKF2>Z`RVjjSt6h;sK{ zl=u3zEl!)V^!3j#Sb}?>44WH#PFE5Fvfrk7EO_nDy9~X(Le6qrY+>9_nGJIDwji)m zvKWcYDtKDh+vIg1C0o}lJtln{lXU&vFbqJU@YWa3i9JAq7#K98538aedWb_wJPK)^ zKhUh_>&i*}FZL?Fzb27sHz1KNQy(eSsgyDF*^?d-+QL&2Qq={Zy%6U=amBxwb8M~N z!IUg3ZM}NI4=bunS=hjOq;yihnq*t#<9sf)(i^uD&bJUn=04pf@XC13{Km|P5&}A9 z-!y)VoG8h74c`GRoNAg50e#)I1bJ?!rUjC}FKjo>aUNA=Kc{KvwL*~pBm;?N8k6AW zj-dtCKx)&6m8z~9V|Igv@gy8^k||ZFZncfhonSUih7^5EVKKz=CRW zD)}@DH3xj(RyW&BFr6+%iiVO+iNYGg&Ku5-U<#d~1jS>$-wI1xa0k9mF3nP;GSZB> z)WasWgYOUt_)d(tY{Mc&ue9M64V^0?6n zdFg%?C8}xn@pkV-pFt<5-FD_@8Vq5h(UwZYPDCC>N1i(BQcX0|I}L8<>sgWUKo+|B zeY(gz9-r($aqL1+Np5)V@#hN)I%Fhkv=8|cq3^^9$h?-LrB`A0msxW%MWL9Y7e6z@ zd1t%%OV*B340o@(E_7PEo4VtiQwa)9>%;TLS7p@P6LVydCJ={rzWw^3#jhB zUXHCszDEq%n2fU?i@xgFp9$YTH2eC9 z(?kY0Y>&==3VR?>6a9}Zi>53Xcv>NTeX(vZCTXy?ipdCR~DCHmVF}QWQs_^ z7Kob1snp)Bw-|r-hAecIs2ODV9jMRWf8=5Zm`#Rai zFP>iFT_iiUSP!iKT=3rIpIT3EaI9Y;v@#mw5^&q3#kkL1wkkC#8U{;0*v=e_vlc#1 z-*+>?Y(2cqCC`*p!r`%_&rH^SQQBY9emWj3>WYZntf|g7e<$d}i1sN^ipAWR&28^| z1P(ZD7b*?j`Q&Cldq={jtSq?AeO`5t?;^BYHKwe2*3t16Pdaf%dd+?^VWvqsuYg3* zy~st|==q%2T59ptrZ~-~!FHBoX&GvD&u8fU0MzYl!*#b}z1o^hReAy66p`e{clUYZ z`>|-(qD~UIi=}rb#z~6xh~At6ChmCmQjeX$=Hl>!!J$B^pwH?43a9lbt?g|>i;|N% z1ztvKK)n(I+f;cKb?d5oZ8e0Din%(#ODk#KJLMiZ%BcdA9!gd}hq;Lwht5*RL~Mx# zTt6MD6)K#sFM%o2;U zZH{Dxa5?84*)+Tn=G-7}n{+uF!r+_#mNEt7#`v-o4e#m9WOv47 z5qs-Kch#W57VE0NfOG%Du1D-%8=q1y+Y>w>bExt9UMq16Ov^3jI|{^flOsmE_zn$_ zh6wCAcCO7nJAsOIwuNKY?9t};nI2VI>2k6q&C*uE&rj5iwyKQC$1R3{iR1q;pJvit zprDkd^-L|h_5q47;vm}&&eq;iSD4(xauuDs4XztMwi>mZQ-r)K)~Q+Dy|&7`is+`@ z7S9zMkTR|aBxJ{%rH#}kMqV@mw_jhAK5i5HEiB8`0YMZngDC}6*9{d^a#%`Ra)wLIn4pwh^+0x z$*<7M^sbtQjAdXQ@67c=+}bcw6D6$fGRk>wzi4{@OeRwrwns?drGzn(si2WO4~tch zw<)dK{v?!4>Xzr=+hX7WotWjLIX9Jglj=gxu6sz)`W{pbq?XPsOX}5nS*T+*fP{ti z!_%*v-p6*ZGv<=Ic6qsBvR$q?eCA8>3{a!f9~P9^_u4k}u^gRU+i|Y;I#)qiyd{0Y zBh(si95Rc1(T|6M@;ClsfH~a#Bd$$3npG*gd^HHAD!sS4ZVGL1gC=C#!MN>q=z13Y z&h4#dDlEPQ+8fs4AK>Or9E+onEu$7d6F)-qkjI59wgm3Qm7^fK5H8_sCLKB50{VtERVD~S}v#5(ZnWxWgNy(Ov5yTME zY8m-fyXfXIjWe!rtLVDyjlnA(4z^C_97SP*W+&n<-RFlJ>OZhvf`hDj%{?5sf|&}O zwIcq0>xwo87&!fPD@9X&fxgowAw6$!Xhx64WnYhl_vSYDxJej&8uJPRg%0P0rL?)A zu!#usD43Vk1ihw(qjo}5Zw6W^)4M(Lc*Qg_1$NQPq_!b1&^B3hUn%DlR@yoWP1>s~ zd1Ad^U2NO5bto-yPN&`~?`p0u8|G&1^v(Twh}G>*sfC z!DX`WF=0Zaibc3AF$I&FIGB0lu=b`Ly%Y#y)BdU%1${ zPwPf*a(w;R>4RxtuD?HO&ubr!MUPu|r2`cw4=NJ#r6!AX%%BC#ScYyBPZ=jXUCPvz z8{B(orjNH9Byz7jpJdc%S&zFg9jXW)#l}^b7$q~eO&)7#^Q^3YC2_3EWvV*=n4UzR zt$tRoFdkT}E|?S?n8KD=KQC=0C0#~F3xBHw`!Ol-hMcJS>_hr!rjvd+wWBdSBAYm^ zJfdr?hzIFgG6SAMG2I8QB|BaRs{|yf)%-blcEXG0X0o<^j?hU`kAVaC1dv^Zrmd#f zZiWd)E(i*wE40;|=lILj!AcsJcN%xy{oXxr8L;qZ#rsBRr{q6!&w@$di)_?^v2Aah z_rmkk^tmE|x5ifJNZ{=g;6$eYC;9+E8rlpHm3T6vi~4n^aWU5OxLz?G;3`Si1vnsr zk=x1?PMBAYG%?1FM$_Xu60Y*1Cq`(_jMK9Qj!8kGMLP0^+?pv58=nig3(C(bsuy@2jAuixo#Y}$L>8IEITbU_LafJDKgA=K^o z1`Gf*vw8!J6U3ZV&jt%49A0R3S1Moa8Zo_Es82XhEpsceJdqD&N)vFbJ((d;pl_RG zJaa7&X&7`f&2!Hg1Y7Khpf#R=~{3DCUscgz&gC9&ZKP%|eNk_unE8u}W=!<7%uEF;fL9Y?1 zY_VHY${W_WU77hYttM1QeC)l1@5}((j`f*qhc;10dFPW-eqdM>BYSB0*|$wDG1)iR zHLpt70*Kc@)D(a)s0BF^HzW|%_0E;oudh&$sMypsu2Ci@h)%@LA_9ETa!LSx&7%knOA+oySMzE`JflYJk~90D6jpMOT&2S$%T$h^G(%1t;OAwUWQ zmho_M*9CL{63Ag;9s;`SYz+ljq1h2Oup%^p-eS&A(z3?Tu(IVsGcK*aHxvAmUJDs*6c^&vM(^{c) zL0z*=bMN9u7<E&on+Bm7{09{SkrT(#>wMg^WJxg4>%RS3H_XmBN z#kx9bMWSN!?8ZqsKZ;51_-^eA-g=?DHavOS!^5Y0&BxKj;%7c20tz_THgu{DFUvDU z@4DhJG*bQ*aQ=_?IfGg=h*Io-drDP>%!xJMGr?#`t4$LVc)|@lp|*XFa(0JJT4hHc z*lbJYU9UFZhvurRQ;WE5jytijh8eT#*9|AB&(`w~ydFd>q2Be2=CEsXUsKK7h<|2S z$0oN^yPu7|GhJ>r2DQ1&5%VXtK~pO`>dI|-HIu89Y-&$4-5K@#CemmMIPWdmQ*JVF zgP%yy77A5(CF6sD>p8V_V8>!G1p|84w^C|@V*fIa~hh44MTH%;C8};DU)|X`x4k| z3#(;5@PrtQp#xvoifk%>H#U9IXR6`byNI1lJJw^HaCeKdA6fD#gI|i7*=8BZj=lH< z4UA>1c$^}4W~VmtzAg>$Emq~OsuQ}OMf!$MLJPInnSDDh+jG*`BaLmhpp~}se&^fH zu;Nv$=6s( zRw{Lt1NY&fo^1IzCN-cCg}fWuJyROoV1!?T_G94&#DB5{9KL6+8^C9(vYSzpi6BM8 zzVYqB5ND7L9Hg>K3KS~=$gQVVx{2=rT>k*v_e1d!Qa}gD-{4V5ki*3nME*ejM)~>A zbpgmI2n~-#e}@wW&aHpvPySA+{GLDx5+Ff=J0dmu2nbYx@c#vXfx3(ZEC_$oo|xa> z%|84#3L_SNE%p{{ii_if_-D<(b2}qouPif_J~#wmyPKEP|8uyT{r^pc{2xt)RAB@} z0kZvmgBea~F-Gv1^Rj~u`|*81U2B}I)*EU?sQ{Y#gwhWo1zZCvo-*Cg908I^^U_$h zD=nT18$O)7O5=B;4*NkgNMlk{v%M7in=Rlp54ZsF-!&B$FI-fReYHbDzKIEf{uIET zE)glWsjx-BIzC^k;M@QJdJHmz%CfJQupe`P{>d?r_%y04A0a&h+ zeRC!LkrcF?#g+(vOSqUAczh|Qh5`8jUKx&1=l+xE#Rph#9Ht&TAcTMhWqZHm@64Mb zIMzdqyU;)}UNF&a-HH7VJc0lo_lr6bq;in(?c!u{{ryiW&|d*~C<9g>-T`m-7-;XU zTK`Uz8iLuLoit?&MFx$$WeuMmctI@tKTrvKAIHYC>h)$tiX=6meC!Iq`Sc_sa9tGt z2HI};Yl0p{E|a671kBkWN5O9!`5Msp7w)5rpI5~|FY?mSnqF)FK zi2y`UJFWZ^Mq7mRy>$r06l6pqMDB8l7&4U#S5uxv4BtXEr+hxiGmvveAIGKNmR|wv zzCHP{{K&y;dDPZ&_oB4?E@f;1}Ec>m#(Lc)n>P!oJ%Q%q+okJ^w938W9R`5{8|# zqPox1NL^E6lLNd6)vOeQms;8RclwaQBWW+ZRDhKuIR`yJ z|8t}Oi%HU8z`2Z|xWL?*oO?3jd3}w~ULjd)I~)2SgA=5D<}&?vN6YP#UDWQDBH6 zM7jk9q?MGCk`fq3kWQt$XNFcf29R!e*WCYo?{`1%^Zos_Klt+l*EMThYn^MIb)3f; zLO}l2DC2ZFv;QkHN!5CkMO^klG2eqL*FO&ppE$-jj90~v6{7f}Sd>jy#_o#rE`~WH z;B$VR9&di#a85ckEsY zfyF|@rCu)#s)9Z#Wa4{#vk-A;Oit&+Y$o9Irb_BId2I!pERVCMFOc^IE~@*l6Bdnn z`K}m{iY%*nn=bnurwVR*eRZf+=~Se<{u2N*JvZw`xfK1ZwF`#Ca}1c1^qAecQLyfgh#Z% z5}d2Kb-26KE2}V4tY4tmWD|A;8F_KuKQGXhcStU-28MH4yYkbJs@gIE zwDO47aL!;FVw56G4xz$}R#_QLP>6;7X)AV5zKXx#X`Grs5hZr2)7HyK(z>S=Uh`4u z$BWLhoclTAmdV&quvm}EjNj4ssEF}w@=gCVkg>RQ$r=&SSYIe8vgwuZ&~N;8kw#Hm zgVfjNzWY}-DbUv`XOAbw6c2yuOEIKXqJ@@@-@|-T-I7_gV{~UNWJIqn#?Cs;y(88} zE98S=T`3FDUh_!GuA1KZo^97A)mnW|k2RkljQ?!3R#6XL`@qCax6{^Yr+VoCFIb5X zO^&~b6zqPH&>n-4<7z;@p)A-974L-(SF2BV-2}>#&9?6KXU_ptO!-l*$cM|JzZ7RF z`(C3l3ccCyn?tpT5lzY$SRbLCORI9>BLZ-=WMT8OARJc@~&}- z30c^I?9UgmU5XS3sMN4`j{rH%u;l*aCuqR7zc!DnRQzGJt>dS@6Wiy2o4|E?NIy@r z{vwiQu=)mAB!-CpEYjHrMzNm7Zn}HT6RuXd(cik;!s(PEDa3sSJ}?h-(ghZB8SDAI zZu4~kcpvNEE!26Q+fB&STeEv7b+Hy)pTKmbIfGS`d|qDKz^jdc%LE1IxY~Ggk@9m` zM7j;B!x^-+`Oko_N+VP}GG=wq=gJyJ>v-me3Y&}P_ON%}Hp=9@GGSm;ci`r{G{8KZ zdmS$}lBMfgQSw6`Nkk42)ywrxC-jT6UCNIp@sogUqo~XH0!px~M<~vD>=a zTg-Qbn}&{BUGh=)S+*u#htw1$=j?#$G(4~k%Ym8gGx6`h|>Lq6)FE@*MJ7lPX- z1%tEkgJ0^zt@%?+r|6hcJ6=-K7-qQ5N&D(gX7X!H9yD+vvqhXb!DoGh{`qAj^IED*sayfs)5X-g?8UJ4*z{P1Sq?f$ zuW0w^%ibxvGK-Bnp*PCzL*e2RwKn6sd0OrZMgswo&H(os$rEh)lqK&jMhJ%N+NyWv zt>2ndgEt4f?$PmYzNj)#le%C^5nQ7w*xgx}OZM12bUO?qZdAo1THnSTZHi0d-L4s! zK#hHpA)ZiK9?Z0%DYlbUy`P&ac^_6zR~yTllQLHbCKKo(HoBi;v{z4^kNjNHe6CLRC92f5?G|ePRxKGY5Gq)BYANj9oYD+iN4$it!WcX+l(7RDO`+_Rey`S!GM&`=< z6d5YUD`Uu4F#w3N<2{_Fmyzo4s%=_`Wq0>%I$56P!?!JpT*Z3%0Ue zX>f|7+WV32j;uSfatqvFMae4E5*7igOqTjztE?SYTwB_$BQn>?ch)CpsA+2>8Bi)X z8ypoVsT!|dobqbr8J(`BO^XhlmM>^l$A16D0pJLS({Y8o>oo2gu7~U^+OBzYPilfH z%5odGG>s%QwS4pJ6gGzMF0jW=7p5ice-U+xsVu$n*-P46?B#DQ@lRaWrafb_mYuB~ zat(Rmo1)3Tly;-K<9mk7Po{S1?5W-Ty=hvz5ObEN5L=Oc@fXtz?p~M(E?64A+Ks6kn=CKd_qn8o)ieF6XE+H3=5--pS{5!lE8Bi2r6Y zBAXAIy?bX~*emwBjT`U=1c?7(Uun3cx1ao@2%Ecjk(RoKtM32Cv@XWgCCCw+Gr}j> zb7pb&-oPjpr$E>`Og( zR~a}TKC?9ItMCLYL&l|ZLB@lL`o~^>Z@gJAvJpEAL12%U_5V0t`A=-W?5sB@8}kxK z23}^XiG?lSKJq&(e&uSoA+*;=_+g1%i~-JAVx&xJRv%E!1SQ2}pE=BbEq%|97F)`U z>46O!s_>k@4gsPtqUq+l{OLuJfR7{4l;YkwQMHKUAE7?#b`h5$Eng^x`{CV^VZFoE zvD~WU-o7A((>%n0wcCt-0szPkRHv?tv8Nqo6u5}CR8I(_^%)Tzs@)6Po%MA#JezW; zC{6if6`HbF7YEq0I-;pLC44A)&Rlwtaqo5j-HGSI1_^C!||ye?A*(8bK_muCEMExq_JwM53!;Lu%`VOd&f%i}&(Rj<+-YTP~F9w}R9SZ+0=lWX?#uxas$ z?NI5Pty)U)48!m9&Y~EE|KdhjfnlfH)&`XTMHfrjm>Zn^|MPVf78`x{)ZLtI^5f1X z!?jl)l^O7EOx*k;T9M0jzRL)!ewpUK`2J>(NNNTkR^Sbv9WAPgR#NuK=F70SIv$AJ z-L>u%KS;NNnoanH9f%^abOnG_<0F<;!v!sah*ZdC`gwVA#60qC9EnWu^QnfT3kiE{ zzlHJGIVEeWZ*nbkHrsCc;2Lk(&NYRs7MQ9%`Ezd~@JdEDSw8UikMGiyGb-14&6EW^2e2*g>{+U|maDtRN$VAFC7*xPB+(zC5$^k8PK!YoyT6Za0c=#i9 z(bIWE7%&T;D+SLKqjq+8dpDq#lL2Q*yFYJU63Qhqp(l60DsA@+D{SjGkS!}lKcG$% zOsFWGP$1BRlM@7Qo{e{%(g$Y<{W{hKg-*aC+)4V>xxH8-Us&5QBd^M@J+NuTeRWZz z#5g=oTRCRe&?ux{;pt>Ae(GZyLa;oT1pV4>3T8dmZ1dJ{7ri-PS+yAuj&Yr>W4}m$ zcQvKRiuUor$SI^U_nW&qiS8z?SWT%Zh@Z_yS6;#IW(>C(gH;9&JxA;l%p-Jrt_H== zS*QBVrQBF#!5eg?w`*bSlX-&*u?5?+9|zu77oOchsQq|Q-=MyZEOpkBKaAt9vbDV! zEiojV@ZDBRvMQL?u@DDd?R5pfP4mF66l5JB#_tvbQq+$`oF@j1bmUe%0|Hl#dzL~#C?A5}BJKHB|<$rm6NVvN-ldPOZHW+WJW^^_G;w0%~#x)xo1Axvn#XorDB;& zTnxFL?#fb$r*h`%{B}=AzmDMP9w4+qo+qR-$f98PLn|$~s|lEI9hO#`aaPZYb=3~n zE9b(0dF^&9q$N^Hxx(#c{cZ{vT_jdWxh@|epH7^{S@kBBTFtk0BWr72ZBkCYuXO(k zkxEJUNYqD)nNkG)fKq>I$oI+O8>@k!)FWAu?+o9A@1dnGVX!G+xoPn(Se!bFtG8NN z@HJi|tgGp!b8YsVFJ>$}=)?Ccm7nVFkqS&^uCVC>?xDeoyHMAc+yZ0KsY4=wI68I6 zl;k&!`{H}-uqA*Zj)9xB=-CI}$a*3FT+{kSt7KD;3bPmI ztGP!@Wz9(iIwjhF8+ST%1j-HukUMsxl0v+KRj%;iWJdQ(hvu9jhLsG@@YPpBvqk6U z@WBF0AOXX&|4P6&LEnP4Jv;^T8ftefYK3{7d%jQ4@(h0>vbi#(wB1sieC6+MH*Wc7 zK;gX-WF>j+n+qo#Xfd)#_+gKE>M0zdJmJ zfTM;~29WNwy}a|2hc+Y*JXW*Q5d{51d9^Pf(HwLF6uwP5XRMT@NfP&&_8r5~Bh@tF z&PiYv_z%ljx&5gBu@t8QPq@55#il18HZhrpfeTLJ-wQ6X!A;2~MaZs!JPfb&h{Gkd zk-gRV;~ly>WH>lTnLIa50x~Phco+S5Fpbb32WE!$BM+vZ7v&EK$zQXYo5;ShnXz-DzW2zv#F)0xE-ZJk%lJ#BdY&J#5HH?kwT~%H znCaDf!Ktg7ggxd;k)6{Ds}9cG65q#_xpuq@cy5*OQM!kXbLT>NG`=qi-FIOuZ8ff1qt6I~M~3@nT+~_}itKLvjMrBD zB8Iu0?Pa7?LhFxePuByGKYR{P*2iJ@U7Q$2X55m6j4paOc|V`|8lqNBkb5g-SI%7x z-mZ&v8+e~5WF`5o)_5LkH3d0T>RL{COhJ)_#|4>ciDgZvCYahP_k=UxMspU{BJMhz zrIBM;L4a@2U@X)kj51V+^RnFPO@9wL@;R$mkLq24(}u};tzrBf`O5Xw;VZm$vturD z*1f>DDwKV3c`QP*+H&?H_Ig|^bvg(4{>lM2J~-CGhLS9CZo4V%rF>3cRa=c_ZpZR? z>=Uu3O$GOV!q~5@JuzPMWah*MI9jg^0|K zMDOz#yN=X-5Zb7fn910y>)~ zLH~U`z8>2>!~w1P5RC2HQ%{07Bbixmy0gU9Z`H~99EeT%1ZftmyCw%-8a|>lc^w)> za~9=lV8bv>r9_a2br=AV^?$;+|5Vk&y=LbH{3_n0p*!nb3^(Z-3gXQhhvmon zxR^Fy%zD6JYwrDn0h^UB>?bo~yoZFm4OBjJ^J5^8swRjnJ;rLve=7W>zk)=c;?9|H zjK$fjTNl3(FJ(YPBQm4tQrTXE@&_n53l<_%QbH|{f=p(*?WGiG(r|UPj`N=C-_8u1 z88;hr+9$#kEJX}7BJgmZ>6C;MkrNiSu^|VaJA9zAlxsJW=F)UvBVc*ZhI3f>>{&D} zIcG;OLrySLk}XCaF}Hml*0+NxKbmbk+S*EST~5g?ueVw0YuIb>9lZ1$5Z~s~pd^>+ zCJz#IUH;5nJ^h$=l$XNz*M80ZpJO3)Y-*qM`0vE6+?&>3wX^CPPJMm@Y=jD@p5^lz1jme2+Tv`5 z0^)Hhc>;d0FN$ikpW69FB(1p!A@>i2q4$y+G%rys6;v0ran-HntWl@L;o@V~-zr$> zhrzy;G9a|u)mKOq6R+Wj>z*@!DFJeXi|0sz_%Rn{q`{z z%hA*W(c&Bb^AdjB0+(?0;mQQJm7kt!QFx0JL21+&HwBuCpIQKrAaRg@E9|;GS(+P8 z{hpXg8m)LKx~-y!IB1>pW6S0823_CFclshKJe3;m;)I3n5D(&v=$3(OZ7Nbos?xQf znHR6gpZ4Fv5C|)2lN16>mWTBx1_WpCXe5#ZZB}!gk^VP;yLg;BeHLDF-D{~i8Wmw; zr5GE(uu>>*!6B55JV|BljUAtb76I#|9eQHNS)N-8YbjDs29}h=0Iuz4+Mb>}8;O9=p4{S7n0= zQuITYw!7T0go;NRxH$M24(U84{I5z`pzs+Asx(+cBPCw{maYH=r}=gi{#bmf?cJb7 zOyz@-j9NJg!}o?53eBZO`@3LqBe^!8)8pcFPXJ@D0v!X11>2Q$>)xPQ{dc~|O~t>f zfJUfFeYS-H|Hnt^BJdQ`zfU3Y!BgUADeWFiUi;D4aHd3JE$)D2&l+p)XCwd^E|<;vJ^>S<_~$Ku`nN5IKMB#-f+$GY zYz`;ge_~%|gwX#M59)xHWnA>V5+{9n`utkZW+W?Uf6~vSF#zV5`tSS*Aw3yjelIDe zKKTbRm<%zxgZ1NzthFOINuRthBbvF~P` z1@`5;I3#~lq_u6zW(@v$>qR_J+N!7fL||gJV91TQ{~EGI1V5gZumwEb} zo*ay;gBWFpaL8D+5{WqI$@IMvA*5ynux25Kq3H>j%`8B&PiupD0arZxZ+u}qlG!XQ51!CsADdgI6{pRONq)~4al|Y9pjQ_ba1zpLa z7Wh9W1wi{y0G3y{U9}SM>nV6>4Lqc3(?M0q`k(9Iz4Z94CqXl%#v-l@(IR?{Qes$S zF8EoSFW&hnd@qBs#h?=a6b~EczL3sR!oL1X&Klq9UCaL3Xg~Oa4bs4p$aPdz#JbhC z1Pd|OzzY%>c+&bJ{mC$vC;(c&S;e_<`6396DX& zqPrbSj0HfBMYI$Ze6gqXS$*XC6PNPyY@;vgoAGIobUYA*wD*xcBwctxh855hiKFS! zlJXu7tnoM)I&6TY3d*{T^A3;bF_waXO4#A^8sLPWvH^$oe|~Jk*;YLi@KOlTh`I5B z3TV&qv)mh4&|)RSYf$i*3dAJsPit5jt%T3m|26Q{`HGasj(VD~Bj#0>gF$p67W?^t zl@F8{30U{s6L_C!j{Z&;Mw~X0j5dzqw^ghtm%P(TPG>E%@yfzMw|S7uZTLHW7o-=1P!P?Kp};nC6>6M;&?vt{~lTL3}XpkqA#7APM{U1 z>&2*lM^?olMUtW;xynCy0}6|86XcFES#m%wxPNtall)C?`J_N=_r?FvP~cS?enmG+ z+A)kR30rW$7Tu}REV!hBD_DSlk}$}lHD&xnz5uiSM=7~&&(545FT_H3 z7rUW=tE7Kd10k^jHcHf!uaz*Z1zVcc{2`Qx8tQYfPA+^~{?2uLtJIdh6oF_85s%fO zpVL6FQfLV~R)D=BekBQgf1T`g(J9{T`xeANKfP7ra{~%%1M<&72a*t%!@Ci4yDb3X zK_3JC{F3xDxOznQ`ULs`OMoK}aM$=A@!5knC?W8=bq)caeOqzEETtPj3|N2|Oo*fL zfEc{4*3ma{k#pT{JgPznsr^HnLmLDj%9Bqo_D6-Ar5-u2odkX9WqjMh6H8V}|IrX< zat8ydQ-#S`)dYKaoBg1jL4Uyaqag`N+mXCYd<&hiBAZjycc}4lkG=r4>r45P9%3Z_ z`%d8TJMXR9mA-aLa|fS8%tU#|lYa{G2q4olHGuXd1za<{xP+^uII3VYrw6?DGW4zx zP_bbLuip}@0AXEfYw~}J!n+<^w3|XJ$ z+Iip=17aRQ8V0#GKfv^8@qxvI4h_BiK(K2|BQ1=dKhzH^HdD<}#5g-KG0%6M|;wM)RXJ*EIL(8D_{$y+H)<=^n8e0T{GS6kebRht4%*T-+A(DHTuNwrDH(I>FPzG%l zm?PE$fh;DTvH2ZN*ert>mS8VeMU~Tr6-(t6}I= zyVQq6$Tehj26M+jMHzh>ke9;8I2^MZaNb2Hx;6@@4VNOBlMH@cn%5@nO^})=o$Pbw z+KUAV;vQDd1;Cn4*EuV?7$QICB^iBN>p=az4W|-%pAiHnK~uff>S6)5?uviAhCQWV zzZr}oZG`W76BqSzYyBS*MQsZe6BR4Yvrn%O{yZiEeurwCjxrXgFQO8{LBIyMh3F{+ z>zX+!m-{|kkT{aJw}VQP@irhx!B}}IZXiqh0o}l=&}%rAS$)BB$0GzRCY?A9SXndo zjG|HQFByQO))>C&W~P>U^!tIIAs)sr)J&S5JLm%3(x0y`kD62P@7(2sQ{mF3{WhmZ zyt~3bCIV)L;Z=KtKcmzUUgx>06MKF35!Y&S~fD5?DoOYZ>%tTKY%$^bH|%;%H8 ztQ5%?K#hc6#B!xru$Uc6?shQ>@4bEXBr!-jO~+RLg}_Tdnp3wnk{4;{^(SOOSXtsv zZ}~m)m=HyJq9D_5+zHYR-iE)=F?FgUGe3TBn|toFsHR$#k=He?`uu@YfxDr>&@x)+ z#mYbi^^DY8n0(Y{R?xyunC?c|t@lg_=4&FeUY2`hffv1}R%pb_<+nG83nK!V7MUff zjWC70>A+kO*b3~3q5`+Eu~TjPL)Sk3@20#>$2lkCWdY>PdVSDc_jCFA@V3e(KHsA) zM>VmvuW@V5$K@}sE!Yh~?70y$^YRxNJdOJ!+)ycS|GDmVvvzJa>~v7$ici`Xbm{7w z3I#Nekxk@?0eYlHknLU|3>d3F{Xe$w(Jf5i`2yXtXY-jUCKiat^t3z5bZ0|Z(j}6H zI>LEh*CLi$WZ4s(`^KKCByGL;4-%BQiD$^wr5=V4q|4 z3{*RS)_;GPTp{<;E&AKod`I*767O0}Dx9 zOA;fN#VnV~sQ)9TYRZY(Sw^qtWwigNB{_>sa0}uJ%+dv(e+RrA`hCBDzUv3Zuw}Zk zsTW{6qY~k4PqvN<{m;Yzh1uZKt=*FwDG zlvF>x{+Eo2q3>p8zswy8E1SXNayK2X+ZocOkSe75>VT0cTmx4;<=Mqkr;z`RUmJ>Z*q zJ)DKB%+?W_otd^`ebT-GIgabpLy>z%50bYMp=!u<6}q1qYs`8_{sWI=!{i zwa2G56R<}8IroJb&pDydd{qIg5p_X5UimV^9r+!#zF9Rj0LVF8qwe$P&@U+DL8_=% zfcCfl;+RP7*2U6A^*xoPDY zs`;`q%jw?HeN;WTST=I#$lZr?y6RSY*(J9y;aaG1xyUPm0nY_xN%`vM8-To2eC}kJ z(#mu{PxtlH9tN#)37S|Z^5h?kq$lp0$|ZF`GdC>OcJtdkEa#4pD2s#!6P(7#dYC4d zwm0!MIi>q>(c0{{=KsD>v`EHGxd5<1=XwsE;f|>0hYzLOn{>FL&$2ooIk&EziF`S) zFWx>m-dP~LoXw%@cE3sEreS(-_wH3R^I-I@$<&5DQ^N+s9}`N|dt?5`f5LDbSDl6G zDs(7kUUE#x@5?mt7?nJ1ro8n#c<<`CQ>tUTXxo+{>$ksuCX?fc`>-jUvV_`azU-_^ ztk<-GLUX&Vh+bc+$&+p|#>s;m^3-Eq$r{zzp) zhZCRT(yEBF#Lp;x0th`{th6g`6-&33hJel~K811VSgZL{s!_rgX@dGC6T*Gl>1 zu>1CMnsZDUrG4=TjZB|_3yDKeb|>T+(=ibcubqQXLPx{?h>8nMyEtb_d;5l5Mg1W~?yd!^h=wNH z`;L_!Ks9HEcr`VU^k;IDg>`sJPumo)kIN^(>t*VJe|WyxUuGI<*c7#b^HawxYR z?T@Ob0kW%!Q4Si09QnlWOLZIYip>qWkWMz*G@R@B_kC5V1fWqfC2~6*bv(sv=`T=U ztTge%&y2@+7GlH)qG-gCdYb4kcqTyUloN>#&sELCO~V5e(^eI#eC_DSJ@Wky2+XyI zg`Q`_>f~2X{Xux?-jD42YpXH2r5l$;ZXZ5Os>Z04%>n;e-qo&{mYE)BPsxc`x`h!Z z4~oc@8Wfw+Yq&h!STH1*(He7<6xqw33h$WK+WmW6gE{;}YL)jfZ->$!MFV?CS&EVE-cscX)Cf&p4GM~KXauH`RTRdY& zu8O|L?!ennpN>6^lns356#;ckr`h5^E$2kY2`JPptvL<$n0?nmM^eYrM%p0aE1FUp zV=sF8BwJ(Uq2ll#!xo0WA1zO1sNEfubyHfLI(u z(p&~Z{91_fmkVyDf2xWqT|DzKi>7i2p%AM-D1@=$;wa&~|G;GTcUl|=oTpJWamS`e z+ufYhsPRY&O#4oKx0nwPKb4~rtvOq+n~F<)d$36At(ZBLDzZ(;{me3hS=$buCLiEg zdzcEk(x4)Rl~c;pTmp%1IUHy|9GQ0*iG9BqB9&?wex~L3<_ERmdDHvv?<)rqqkVY9 z_r)gp`QZhJanD71{Eh#b3D%Pz{lUDB4LlvqBv|JtX=~KyLbRJpx@;%=l!Ufi?qyz? zSma`IfNQje|3lVd!`UF7D(^DMoy+K>!QX1u;f#_rli0g?JltM`|%bJQ~4BKv47&K!rKWD16XPN zZH^C7%X(mU+H`peTPVN3_F!IFP{z@%R3{wr=2>A_vMXtRb2fbE{UT*j+dSY&wX2VSnnpq3 z5dpHY0xGytItigFSFnc>)Me@xoH1gln^l{()s-Dh202p&-@Pbr`fGjzW(Df>dd^rl zQKCIQ26)XwCcx=(sXqNQFPt8JXBUDKL|XX1MNdp)DHeHPh@#26&<=l&iGu%x%U1NW z^oYGO|7M=|#C<6zlIO7|3+vJ2fT+jGZ7Zu5Z?vA36+c2$YU;kBRLNs?C!<=A2jRni z%;W@8bgJP)(Kl9&9vch+NJE?Pvj9tH+v_vfnwXwinXFgCTvgDD$`9I+k4%Si+S#_IoIXv1=u>cr99&H_ zRuf8D|1)x(Wx~#-2zpidp(tC6C@5o*K|F!ew6=qt5NQZ z>zF=VqkvORb#8zzYlzl;U#PsPvW0>-VpAUHfQZ4!@0ENkElqQ&>Z=}Zqa`?%!>y&K z1Q<5wD)NE(?C~ptSau#KLidml58re>+L=sre+eidQ{JnGnM^goKJlAWP9s3B$5!+pqf- zwN^xN(gxq6AgPRq-1MSl;H!V=%$xc&%yM&Q<4xwGrUk1f{V0!povHCRTTJZJT}7p! z(iuvj*0PKV9Xz53R#&{^YL=aTBxvRM!jvca2|gM-OMZr83{OMVvTAX}jXpS{(w+p= z?d7Fl=FXQzgg3uHQ!4Z2+4;$xOSQNV?#Zg2=WhC{a#C%TP0;kYOs7G<1|j2<_IW)d}R1zi=M<%Au5B{H2DEX-mYzN zKpt)tGF>)pzAfJt<}qWqlzNx(kCH5iHgtk$L;qbgX*B6uIM!3fmVcThU3^vuD&-Ac zlqYVdZf5$kDqJjl?83~Bh$j>#r_G7BxR*II7FX30qjmRzzof}(@G(r?U>9D==z_V+)1i3k5m2@|6)7Yh*+>yqA0=&U$##i(%RYL2KqFtvh z2RFXwIOnkPi4x(*xI79R?-5h+s*1Nfvl4PS*HgZStQvm zC#4e~nN6-TQ{`wJ`69;%CaNQ7S$aNJC%NjB4B{Ct8OI-Y_Gxg|{HHa%d>}4~AI;x^pICZEX~M*(QxTh#9+8*Rfg)vnERbhavoQTZvnKM(4sonUu#KsxU zM?Cz+hR*(?#jEh(-EkHbZiq*Dv)15&9B)w8eRb{nnim$tYY(i?`TTbg@=n}rE*FX+-!9eov>0k*BCyU#vFrlz z_#80US7^+DNZfp0y`SUQu#kc|#e@GPS<+G+Xj{~^hzz0#<~TeC5yrwh-F}_Sh)NA5 zbHA-51oSK5m9y1$?Du>=e4X2Aq_0-ChSQGFq1^;lRq|vr3$xHW;vN2<&oW>evjnV+ z2d`XU;%+xJJ!2D!3Yv171-J$E0$`q<{I%+D0}3~`M;rCcf%~&1Z?9MDxjnn35l<=F z!J6ci^YNakz`cAelN#tfvRh|DIN}ahzEVEmKudRQRuz^uC9Y*pKla|W5^!79sBiRn zQ>B|@R6$Xvq0F>SVOb8WZ4nPWRR8N(V89PdGi|KaK3Dmn86Gh?M)|xbC+ZH^%KmE1ru~TbCdA}CmB z0k^D+P=7G9XW&@W=Qh9cu7HVYkbvtoNIx)n`KVpEb`QjGn6NRN?=2K}+8H*s&G^T= zbLfV8{5Q4CInHHJuzqMgEN;EpX=ZXcPS5nBv?%6%@nt}y>;A@MuExC?!%-T+89S{e zpBeYjgRPU5Oc7_0hjR$+RoNuAeK`*jtm|fz9udH}7nlE*1E>0?y`GxA;av1fDED6C z>)1z#)&qYQ`7MYCbJWG3FglE!0W~!nUGW>y4Tc9k!+jqgmnpJwsu?=0pWzHsmOfdp zDqN?ayyf73i$nZv6GCtyWBgaLwVp)SOd<=g)r#+^8mw-cq9EaB7elJ>pF_k^r{+?9 z2QqruR6g9-G4ns-tJY&u1FSd&KK(jL)pAo%lWNj+Gerq;Q#B1IRHYgx!{lANm-e1Q zJR~nt(=>IpELQN8BW?`ePH1vq?uoan(h@APH{G)~6#5~U&X08rLPN7t96fDbg7l-P9k>I~`{2JqCBaN?oA4A^^or(r`l+O2-yU35>zmwL6RLNGZZ#e1u zXIrUe>!pXwR*Xv)eqr5SS-<@3L6h-3rRbcPL%baHvAM=)5A5e`A^UrXwqgN#E2x zk%*nWAb@!Td^J-)raGi$py`gQ1P-KXZfo4pqW>X#)8pEjZs+3OiWuJB-6xuySCAfZ zbT{1$DJmijE~}ZJ3wYPt@=r?2B)HSZLIrz<5|A`PaH<1u*1iRN4By3<6ir6Ng-4>( zu(Fl@x(7l%cd2)T)!9NN71DDT&ExRGgM%3=BnyXjCWgxr-cMwfy%YPMjkk!1?usHsR`dWYpHVzSdy(N%#*BN=4gR@I~kgQd=yiu ztRfI$Twy2H#uJr_So~`}L z_0FUY&gCrInrv+d&j;%RECvB2DJu2nfThnsQcXNeUS)M@+XUiq>6co3SIGiq!>4?R zzp7&3GSL%aT>8Uw{A)SLsOVRCPlifE@f9spH0pn(M>HZc*e^^$N!%do2avPcl|QYb zM~og_r;w(vhWxPJ0r}?_Ab#2h;zD+=$Yl@2?b|q_ELjeZ6zKeacmsa$z6Y?9m!M4e zC9X$ISlMIti4Ab0LVk|+sBc%co?iYtxgWRL+1p1z*PF1C}_w8 z-^yepg%sDTLvtLbs`N}Yi0^^?Ia@49>OEB(14LpR($W+7ZuKVs|4}J00V+MHYArci z5?Saq>~NfxjsBRV)|xq1j&sc5Za$+&feo$o;C=V*Z6JiH{3cMZHsuF-+@Jb`*{{yu z%e^EW3YNd{=U6rpK-G=DuOZ&6Xel(72XlNJkPdpt2)=v!OHMEzlmnD~2Ik3U72d|N z^L71ZdwG>~@HILSZ+DGh%SIE=6E+p30&Wo&-7}rM1+Zk|g0TW-_+HPY>m}^-F(9EW z2zrkQCY!toUmGimXGOlmgZyIPvl)2E2`_k&@+uIcY*LZMUovj7(0~yv@$L4!%v9{IOf}UqwLR>8PpdV3S znXS*UdG|t+pm=r3iKif&4F&Yg)eQ9@g+^|l0tKLSEeL4cV%>g#nW*R&+bFTT+Uim_ zkGR)KKja4FF<)TeHjqbU2sZiR3bHp7$gux_8g%o%*izuUB=Z8eoIY&I3COjT@T4sx zCmgIzHzJ$Q2VVyZ;R@!n0~BhuqCrxZJF&~4&jW&pi0W}0Foz44qDMf#CK9rt)UbFp zv2NS!9X(@5>wQZP0BwqzTLI0GhTeclBnf=Q1(Wy%f!E9vxPqu5T2H7K3C;t-k+drb zetZjK?9*56mGaFXnUt0-6Bc?aSW_me2Z)O<-iti|2-w3n@tkpoE_Qu7T)ch*@3RcC z=vQwd7YYdIKnMD?NPN$lwTzYMV=qX@q59`*!J9ZLpfQ(p@2zQ%DaU0HktiqHtQeFO z&NJ^d&cnrjukag7ByIc!ws@1)D@zpQQ(ND)e0ZcG1LphSS3W2BnKcN@i_aDVPxoH< z=FER5PgVtBgC15sfZTDtI6zI2#egF`T4vt7tDXTUkXBgF=2Z`7!LD+Hb3=K>9W8rE2Ck#El(8WgYO4|Nr&6A#+lW2qaBy`FAmBA zP1PNV0cdL+ARys*x|0Cs_D$Xb~eNH}h_>CF{{|e71*+ z;N}AetG(z6w^7y2*g-%cTwCo8MzzN(I2p7XXfvjh)BN|9(_Whw}G{ryeC&j-c#k^vaPXih%Rhsu^X$k@4Y-07gi)e}H3p5&RkPR>8X zxBAoqxMKS%QD!hzkTPl(1>vLeC$}9!v2>yc*CjQ)8$o=aZI;c<1AtxG-Ynh0W?Z={ z$FyGlvKV`IS20u>wA1L?-<%RD+z~LqKUM}K7TX{Q!HA?mMcM^MpDw_AX#l529gYDF zQ-q6%IArKKApj{RklTO+mqdx7)h9X3N+YNZGUTTdfAkRxWt*=5eEm6Sr$^_%|A~A8 zn=i)#9ioLN(4aNbuj?&}@943VsGRUQ?-iQSY?+&!aFh|Ol)htt{?SJ))U(8kKpcRk z%5T|1o#)%atC~FCVoyY`WO4++khTDDkUffuO}i_U+Q~Lh7L=pxN~T3Qg3STjq*yIr zi@%uJ0+{go4p8RKHz&)?r9e2Mb1`rziVp?ph@pXjguIrx{Ns?t$?@AfH^GqP zkY~zdiupWK;B55|Wy@?X3+BOG@8MjM)?hC;P&@OA=;{8DOh2%$x5~a70Gi6R^jZrcIC z_w)k{oK5>d1Sj0!B*qp%atpC|SzW{k8~8QOAdn;(!NOoutHFW9ruI+mIXrNcvDb=V zYuKIYTx|bHY=ux3(|ut`xe7R#rgJYv|DX20G@Q!zYrjNf&X{CMMT9cfHkC+-JQR{C zY~v>LOe$oq2gy8TN~W-pF>|JEifs;|*v4%Z{%d=F?{mDz|9C&WAK#--?gRIIuX|nB zwXSQO>s)Jrp-?3PV-|A+hz#kWN~5V*G{$YniF+@>I%hs3(DeY4z8i3dmLs0U^|Ob(eRqg;_u`*EcMI5F6$ zTQ7mY;`;&L;4vg*RnQXTVLu^IMIgMDp7)GLp>yQgMUVTfo%)IAvCqS#yC9>=AC4Z~ zDhRi_cn8kFt^P-JtWXZwakLAxYlsC_r*^hkoS$3+@p?tyoVr=$J@KL`52))?T(Cn5 zul;ZLlAL-Bv3n#mDMB|9T31+9I+GKBxGR?|;k~4_33Zkn+b(Bm6(feBGV*_0``h=7 zU@~t$+=nlvj3U?PQ5)Ld*&W%%xaX(vx7iHh>{E2oxXC z)3H-kz{!00yCX?@q{MDwJQznMj^&i2qpUa!68~L3FsB-8z0RF)j!F6kz!xkZ22ir* zOZ7pZMkW)>9R7CXe|||l$%d+5@O35bQ%G|2lLtt$pZE))q!{4=rN;%I=Rd?2amd51 zYjA73xdL?28ex&Rc1m;B@+0{|y)7^7Q$vli^AKsK3*c3JKeUn;* zLst@Dm7cS-9k8wZK7ZAARFXi%)JgXMc8unZK-EgU4@;1k0e$z}on-Fl6V#G*=YU9I zFcM-9W6}<9)$4lA1Yes#?=}_0gUj0&s{P$dXjs}MQtx%oV5VEOVaJiv2YB!dBSgMD`Ihf@hhU@q&Xwh&Qd$Th6OCZ3#Y=4)r@Ds1W9+k*p7$8bV z?4mS|{0QwvjYt=LPSW^w0FBms!1*R?cpdfdKBdeBc*N_fP93h-U@>vOw}s!o;VhJ2 z6E~rX1EX!RCE`YA`WCK=^C^a(61#LSnXhocR0{loY9{yl4OuR8EiqhS#7FP7h6bBQ zg2;0g!&R)aes=(O;R_ep7c@cehzoWi56ON6!j)_drR9Zx-unV5rvyRRiDC&x_v6BI z@pFD`C3`GhO7I9S_xPe)oU(AWvudWqee>wOXGLKM2!hbI63)y>eddzMe>5Twlxu>D z7>RZunV>5|NdD}pqxTM^z!V|~5-5j+E0KlIDO7ikG{Qj#k07XswiRj`yZCsq^eX-H z-jk3$KoCTVSeRf>bq7gMl1CcZh8oXDD)N`}fh+tp49)XDLqnJne}?9NwQ6oLVKuXA zERYzVmfu^ohOxlbDu@-Cv&I9LBaVbgr|wFv7b}pW5XEb4i5+mbj;X}70yo$?$!qOe z@vlt1chL{$-_395S8wt;4(Pu4NR^v6&?&WvrN|k=_o>UqLLvr>G`hs5YBdvlx)=*f zkpx4+beA@g#EHRKWpL?>B$-yYTq{wf@1bxlF zw#b*OMEabTYXWwFfKQ;(S^!~@qsX&>kPyI|1Vi9Eq;5e6q*JnxZ!SZLp@5%0`yx5AdxInpCD~SKxJ(K7NZ;PP@NObb=o@X%OewRe}9INpoU)P|A`8 z#u~q4X`jzzI^U&YLE^_2*Mh%h02uJ^R$k6-LNebpk>~FEV8?9yVHMd*%8T%;%=)5f z254wKC=4>E?fk~8--{8_EAsaAXY?^Ee)rSAUMtBR`3Z`u z33bF-=c6E(NuU^93ue*ur~VS-?~vfq&vnwNTrn znYY%FQVc`;v7Omzg|TSVNXxSK=1>+o*>xfj1*P$}emLqSU zENJUygv{W&_ATJfGV3=PTG#MaN&4^aRJQiY8RU3 zn<)-QGaIKav!j0Du`g3x8!xnoHz7~pOx>H4jd=BPi`K^lgg*EFE}K=C?BS=Km$jaT5z5|f!(i+NA z5bt+_Mxt$faw=up!M!X)p)kW&2M+H1dP1`sTA#Uw@#?m>0%%qziiJ{h+i~RB=vg-E5Y7x zze7Yl4muC=Au~f++o~qpV3f?mCDSIno@1+%y+)DoiyvR9bs1eELBA|V=9qVRiMT)d z;&_Tb$WY+n#{#8BAXr2w{MzFrh2Cfk<8oysdjl=F5~X5cwDhMOK0PNw)l`~ z2=CaW$;Aoq?j|JmcNf_^y=1l5_NQJz;6x)m01zhikj7n!rYwdk1IU`_e06#-e!RL& zNHU258*ew>ccs#1;T1-o#Cd%xfORRjcYRTK!rnFIoZnd4GWG0a-~#in{5(i~?RU`g z-47c}-ChM)A;>2bW#TlLQgq%h*{OGEc6(aA*pitwH#uhOG}e99RA(AaXft+-tkC7b zr2!!93?LC`indE}9YjG>o<=w0l<^76ECUVR7W&n_;c}={mxr@S6L)f7?+GNoB|vR}58;jJavhK$cc{asuRSZnCe(}sI#2%@p0$`jDA_z`EE$|;)}KJ6|CZBrFY zQuRHE7A@koqb_3@Lhql1B5`Cp+_C8t<#ZH8x2j2 zJBPC{yM$i2-!>who_|cm;FrknDtv~lU;UV$G|-!vwpUvWgKv7{J0Bw)5mx;(v#Ehg z5fRV?4LN(T=gZUw`H`hifGd4^D_VD|q_3-JwP-1&p5r7XR<>-jJT3oy(9 z_eTv)h${8UN&yH7m8jkQrfgpceeB8;jhzwfZH;SZ?HYT%uEn=1igC7fRPP()itnXCjuw z%Y}{Vih6MS7mLWWe<~%*=*5;zBpK%_H7*3+45a3-)O_v+YMq=NrK0nLq)TB=vMBHe zl6VRg?v&xg_O81wCG0%$->5-XLm{=h$T8=+nb!}ojm@kDr!i_^efQH=UiN&rVF>vX zGNHs9aW1K;sWwY2H5*(=x0XW zp1C3ze6e=D7r!|9#4qoib<8CVaf`vP;cVKMZR=~)0_u2erVI~v`3_PwuM|f9RuO-Q z`2wm^`-ZDt;)~)VzdZc3;oDT~o+GAOly0o0`N4(>E*#N3JV?&saj?VEBT4Q`>;2tt z6$$Gn2-6dBYCoGShv0Cy_%sI-oEnx>#AhLFb)ah`L9A@d0Rwb8#QGalk3*bK9UV-{ z)cpl5-zSiz*#(L3kKuaK&7BS!&{ihT2TNl@!v@Y*)E+n|VO^FNG$_asPCCK{#M%B% zZ61GL3ER+CleMyId==99{R_`XCZncc>QdWluB_V>0)Z7V;*?L)!^Fc3d6Gq=GVR8k z5oi5}vMq9VMKB3GjgfPebIVnDG-Li!Dbs#M?9%(Xx7%+E;{HC~RH~a_=duwQ(WbKo zm=U()3Xs<_?H}(N2ePpVFjPzBe;Bf3W{2kNp4fN$+ntBfJ!8RYi9Sgv4q;QBsF=O& zDXR(}D2`C->4=adcCpoOuj9NA$n98pogj77Q{u?_`z@PeTXI_uzqaIP8ix07?Kjk9 ztvmE(Ve}-i@4h<&)y7L(FE3BSN`ld!7fKRfCQsfN(D9wwbcD@ZF)hC~u4JZqt0vq$ zG_rVv+!+j7mVW6Dd*r!D5iI%8&)%82P)_B00JY$k*gFQ5oa9zlOj-;$YQ?Pj!;nz^ zx2PbJ$a5wdp)j?%SZ<)1B&uMH(_@1%OMT`lBZ`g(8aEms z4Pzt;2>6e%Zd+sU-J+#q4kVlJ@6wF)ziiVO*V$RL+q*4GxfGUrdBCUsrF%g%^S(dx z_$OVHDe@kAfi}Q}Cz=9nt2YiTjDNVmHzmOoUU4{RepT0%tj=855eI zcF0%LEOhJt)`{mPLSV)_m$3jxYzT0_kUI|BtEHcSeR%`5UgLSiZb%Xxu`#e9y&Ag= zkuG000XeTP)!*K?RGqT%zxlDGSIERqH{osJ88vSry;~0`5c)kIi;>CkntzL6zk#Yp z%e!vyw{UR|qcm7oah1zANhqYECG;n`35MELqr`2%Oid$k%w>fCLs`21GX%I1_GCfh ztdc8Qj=R6+<}L5T!9J?eQ3O&_Z6AM?KkNQ@btngLgg*e@tBJefygIOHf8x+HZF|#m+4}-@ z=Vc#ROaYusHf?=c0_wU7>{wWc$pJc9V8#+fGxT^QCWNs`*oKbT>a{#*4%^dv- zF2Y%zl?m%O>xGiIVUM`L#|H5uY4)-Di7Uz~TWD`g)3EY=OVTfp(E90uwZGa=uZ$p- zw^HObGo`?}(%#*Y9ksL`*($tX zar{oXNnD2X_V{g#Zy&1bFSA&;3YnYEXmfX#Jbbbza~dXd{t z?!}QWWpiBW`?In1*o8`yw~w?ko6O>jabup5&lSvv^9uFKoN*6_2wec6NP$O5?spr{ zB?xb^FQO&$jPw>@)Tq#s?7A(Ym9@iGW9Q#gt41lWO!HE-x36YOdep)hOvj!#Jlyh< zw(OI)kF0Vm+E>a#dnYZCIvGIKDW$`TAggRWHXq9xe9ytuL!^stAB*j8es3MJV@hpf z{d67E7!-+;uJ(bw1(%<^B;EOpzr{7@e9|z<8Xy=6LeYjfyY4q&8AM7numEM|oS{f> z9n8RXdhGDMr^THEX!lPWZ!}z90r%qiFwW!wV%lxJhg3l?Mt1mT*4?tbI}tU~R@Uw1 zwgx0<;UZow$dPpntv6}A3`ucamAlZyy!ETZLL=Ha1r zq7!sN4E4bGz7scMTlv*%?Q6?s;y_+Zh&cPM>p7YCLx;M>S3_Khkylde^Qm;0-9z&T&8;j2omzNueOfmay*uUT5zwkxJTg!%3T-j0FhMlGm%pCkIcL1rty22H zP;gFc@b`nNo(?D=MDKs$oL0A%$^PZ28Fk;!y-DdMZmwe+_rL5c`HO4X$0uGX>8^Ll zb(%316r5Mr`@nC$4fRM^Yx9P@hB6%qxR|FGWXF~jSJ-DXM#hRvgx1TSRCGO-6zco) zC#tE0AG1z?EW#z97>Vvxx=(#{S0|;G8Xfh*8{#N3nks)~CNC>?Xjk~17?$oa+XO~) zU*}gI8bMvX%Ew7QyK;CS1~x78S1D9#Ks4bZ5>Tu=#)>qGl{QOkfwd3=ceTfc+%jQU zgNC~iEZ%QL>1%qG6LI=)fFdF_9JJTU01M) z;#fONq)!I~rufOJ-1|yUBZB1DbsO4M&&HK%f2fBD-JsLbXwv~1ufcb|OMRKuPt3O& zYbT-{TG&j|S6=T=YeK1O4u5_WM{U<0ti*BY1k5V9*}t?EN&I%ee9y_(A18aSCI|3e z`4Ael=Yi4)h=<@jpO5d4R@O8G`CfAY{(#so(UoS-(ThOxiq^PkBm*XEhtT*ohrauy zA}|y@7;ZThYVZa#IbUig6bCp-wfh1Vu`yFtO8obUzIL3x8Jl8PuEa7!XU z_o*9uC`CkwqfQgfQqykE0@=||4a^faAgH7=q)@8N78|-eut$gGV*DR$2-Vai)C@|f zyC<=KQMuCf<-Oy&YIAIed^!O)Z_c)mv5bWIikug9U19t$&XN%!cwI%XuVIPGZ_1c# zMc2W08Gdgo>)iLR9sGAaqt{jeE_)^X<{T<&R;UCb0iDx3XM~6l96qKDv=&i-M`+yE zdM0N@%wU}tX3ij?VV0j+Gj33p!oxSb&@FJzMIRQj6PW>O7!wv8 z3^BqxVDs3opU`Xr&q$D!k&){)q1uzp9?b$5Eg$Uf*-4nq^3&L%O^yNi6zDaJ`=p^k zsOj_yN^EeO8z~RL{buQD=ZQZ-KqC=#Xe>>93qmc_-|$215=4lRg9Z<>C_<$qaXvn1 zcgRi%6#k%8y&MmM+h}RMltaUp7ca_w43x`;N2Pkmj7%#UnUDDD&;Szv?3SW}0vRFg zj?5+^!SC|FlD85HETQ)UH)zQSWgxXM=ANVfk^4U3Pea++WCVU1a2wu;T+R4b@*()n zf0U34N5sJS9h(T&|H%EX3XUC*fcYowa5D2>$vH+A#@0_n9Ip{ zeVDhnUFvqO?h8a}MiRd+@DWN|tw0sX5&Anuin@e_Y@9|%{7KztJH;|naIe9L7{cQz z#MF|O4~lJAk%cFmeP;u}h9tCKJp;9eASg{}{90!YYcsj}1?u1593zT_K*gVb`=gLQ sGvv=&_+uRYSj&Ix!2jX3a7go#x7tTlD3c4Sy&t=)pmsa|meKS72T55D@&Et; literal 0 HcmV?d00001 diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png b/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png new file mode 100644 index 0000000000000000000000000000000000000000..466bd1f4826c66dc994987912a45752a5d97f4bf GIT binary patch literal 46438 zcmeFZbyQVd_clzagb0X$(jg$--J&2}N~ge~yIV>G1Oy}wEh!Dsjf8-7cf+Cc5Ypc| z_x;rS`@V0C_s@5X_c>#1&pvDKRddaG&1jAo-a8N$*VPyXhM*5`MovhR! z%Xnn@$;D(y;#1*Qe#zkEulx$Zr6Gbjk6|W@dSM8E40+YObe^IJ;j%aivzpvvC5xcpQ@492H z6A1I?DKdID_%*oWh4DuZuNV;l`OtByTS}SmPeGa_NKc5cu6E-Rq50^c8q-<4C;~*V zhwN13G=rh*r*Mv!zT!_iKH}!dIa0S>*r!bMELIMxbB={68^Yd)K$1+ zKwRUq4u|9GDgU|)4|}JAlOt*i{kJKNydHKO&8HoH9g=c?RV!gmKL>rz`W5@g zk=Z=tQsU-9=88kCmSD`e^TxsM`=dAqI$I=Cd!pJ$56**`8eYcF9@KC$6;aLjg)fCL zjoC>%WM+IddLkCqKLjOqB`yhM{H=SJND)_R5qJT66!}mu{YvsIxD1EHGL)t5k>N&J z?9{y?jKT-ZyZjR$stl#$-0izWwq_kr3p-CPVzFI5?n4hW)S)tUY7uIAqeSDHY3AB# z$B;F}c*TTJE>5^6>7QS?ohyT{T{Slx!c(t zL?7sGIv}0ZwM(}xAEaQDG;5C`@%w3Jd|5B#*PX^)iVb{pAnIfIu+Ja+bJw$4WVWRT zC=xF|p*c`u$OkQ$wfV53s{NE_$9dxaxQw(2dEF3y3bUZqwv1vdkk0;b%DseE0(;cC z;D$WZMx^;AvOk|G(J{VJJ$yyMA;FC2)$z=TM)VEk&aapFUxzEUzp zJDOmrM(j#HqM?t*dPCy)z~hrMb!oKHi;o3ag5U8hgCk!}zj%NxxjKYleqZn&i#Sn+ z+%RY5!9tx?hxs~@8DfAevcFSYJ`3| zwowLH21C@_SK87uuLTwBWw$APC<~(%x(C)M9XYjLvOZ6Uaf^wHE{#c#7E|M9h<~Ca z`~3AAsG^qCw3J1*ZmzSks9M$ciMMu^q&(q*QQA_)IceV(vYJ)Y)VS2(%Hitxh5hfC z)Mg7^_4mHbNW0j$2)UGahSk6H$-jJbiofkPwm4+Y&6GsJ0*#l}kuA(k%@oP-88S5I zw>;zyB6i|tou!jb19se*@z8^>y4WOw+z zdBjNA*za-e%qq2=fC(p?nCdv!jA~^s|1?R*9j6#J7xAxWbi~!}ntNr1 z?F&2DlkideErOlB;f8PLGU+)K-_wg*Ubg7pH6z;^n&H~8j~izFd=^-;EZE-k5$hun z&Z5{gpXAuk0Q)cdkIU-qgze-s##x3m`NM))YEpW?D)t#8xh6<4sgPSqau7nX1c+!hNz zyqu7W#gf^#B3ttZ3m40zc3OqEovBTndDT$C!ggI%8qZ*TYyGdwH0=IglaMfloPHOb zOqN;RS=Ycl0->>ian{k2w*4O>mIn~F>P5pv-T?MC;~z{NUZ9gIn@eUyl35k^N zvhRA?<=K@$!y-i&rORkMIw7YO}G#tMu z0dfU$3XyqXxbV5x?fUvs++Nk_&c)Lmj!cf(U&3d+aG00fk?pzBMfRojnZ-HJ>H3xW zrRXu&IsT>1x!14qbrT0y2mMQ-i{5LTi7W{G(+T{$eqZ={xkkZ{_FlY0eu0S z0hR&vfqH>vXzA!wK|#Sq=yjc3D;VzMjr+>yLt5YR+Q-`8xBqS@51tL~Z6oQ*G?wft zA@*Wx<9zv8g#v@xBf60KKw3*$Ayn|O!sp1al25+GT#OJ%U!A7ZZuV4a`N7KdYJBH> zL`|qh2Os?qzUt!w`~~{^&r9U3Dfk@ciX0MR9>#RYVE1IN1+DG%h^(S^l|EaHUP)5@ z>gW`38f1abD$kvm$aS>&{POv2Hfny*SjSjuzQLd9Ygu0vNHiVYqPXvPggx01DzrfFeey`7~$UjE=UZ5oX1TZo=dj!mqfPe@5l z^3u#?>4{HCvljN4S>w%bs(dpe+UfK=iDCW+`?RY7bvmV8_ozR`B?~4=r&3tRh3Eyr z1w}5Mya>C3Rg#HzuXDpzc%B#&I^!SH`=XNUSsuAteI8sC11o(qoFvdx-Ug4ziyCcI8^{BDYd#ku3?yq1n-!t)Cr5c z8!dMW>c@-5I|~;}pp~$f`EpyiRby3QRePq(rr0~o6D$VjIl>j!Qo(+mX{R4|h)Vrh zBx;wprCK7Yl0WmtSzlIaHC~*>FR{49smJB$gzCU+MCaqn_U71j4GL;Vtl6e-T|!Kx zz23=>Z&{viZ>N3~N*K@J2}>H#V`wPZeKa+%yrbM;isPizqhF>`ejeqMe`ULmGrQ>h z!6)M^WBuo8$kULpoiFuUF7v0*(>-h%Y?}~C96mlz9&*7IcN6JY3$;#_`K;bZl_(be z39rT__d$%U(303^^b~wbmd#$4bNM@YSxPl&7(!0B)F1)0&OhO8%i+7> zrwMbc<5jiQq?hBTlaj@@#V!f+Y<#*$wIfr!xqDOZ77OW1@97G8rMlgWM0_H#;ajy^ zXpVC%ys17sg9{0`>EFcP&2BU}9pLQ~EKW8WT(%xctRGaj99XzniCwZU8TDpONxM;C zk+-;0`yJ;P|5Te99WkRDEJzm;v)g{S-FV#fM@&%vje++*e zmSCTT$>iBtruE)GKIte=mC0>JklmY5k+dHpJ#@Ftj?_rSxx&m896h5tOfTDhIIS^< z^gVd-WLWQT2S$kGRfBAJ?{td+>D4gOCpgmP=5V0P67!_n{#!z7VG`@e%cS_^`?m@z zsWN_tUgkJo3s?=!@7Uy}0uG*49BqNvBFt1%2BM&V#0c)ukR;t3NJt?TNNE3fM-hA@K3~9vsPp$XYRpHZ2jJIZaJgro{PS&8-VD@#-XkA^ zXGjuik}@*jTg}+f)YR6=+|HSyVORnbVA#LWaza8Pq(xlFGOEwP>&O!pZ#A7Y732kt z?QB?$Ozhs9vbx*YBica{b{7Pfo0MwYjsiy&xN#o0}V}8yBmcqZu2A zfPerSJ0}|_CkuFk#mU3g*~p#6)`{ltM*i8(D^n+9M+U~^7#x+5jAF0ovj3L>eL!maX!SqFD1scM zu%G+|6EWa_H-S!4^1lpO{J#VI$9nx&g8$XQf0*R|okk)afuG236tFc@WfhSSvx2}p za}Sgz&r{C%1~mtf56gPp%%D86!Z!eIboxq!#(e7r@M4M#Q5C zey?J`I-*4K??b1w=9E;B`roZd$zTmhb$e8?f!#Icp3guIFxNj9-`%J~60(b}^a_K@ zn)=BufHVdGs|?Eg_x!b~Ka}O?Ok^&WxR0V@l&9KKrsd@z-%N0v#fnJy~D6M~FE9m2Pi-9@b&VC_&l7iHhyM!%& z3ZBM*rfG+lkde`|amA;y#h0I;S%!kg@!Ekuz;Lu4i%*%zBsWri@dLeF*HH1i;0LYz zBuU%`a-;=K%Vz#WMZtYT^`T70zl{NX>@j%EQgz?d9|`Tp^ABY`F}xlUly{()3p+_m zWqZ&{03Z8!AQGA^X!`4`zz1lQUtR*}M+sKL{f-d6wk7s&6l8Q08Bj=Ta~vRk3wk+T z{5Y1e1X{UgXISt68T~70nj!Q)4!SsHFc8T-%sfhEIq}{lu_K6xi;JQ=AO1_BK{%)_)u5sXJE9$*YR^gr3l03Zu&3d=v zwihkg@5C43YN~0qp$J(FkF*{?ew%!uWZ-8fdUqhrdg;t1Zx^p`*I5vgbbVz4hh?!r zp=QaQ%ni%G-{nYT9|Id+zgWUTM(+Ujx+2UUAl?s*>#37^9!-B&Y4fDvth26%OvUX& zahE#(OPaJgj#sDm zq~1cjm`0vuQO-@+f_3q3x7)7ODIw`zyxw!c5@$2}N&L?VTLg@EqkCxo5aX&{po zIJ_18^UVxB>w>Ipbd&wk%T!+k0A9eldvH-AXg-vMVkK{xCH?Gc+Ptm)XXoJ2nOzGB|yl@6s6N_*dYv&KYpmBLrbOGcQ|5cV|;@1Bv`XV+{(-0m;{ zr%WSjsQ!$IRcV976;E)47%Z}7GH|g#QAGr=6}iq9%rfDR#myQysVCWjx9j}&Za4a7 zl^P)vQ${ouV>7+dr&bYUK?*6CZs|TYX^q>+T!myRT7HA95ZJU?C>Y4*&Bg-vhK+~r z6MQ51muFt>_;^wJO`g#G`3C7Ogm1(}2fe=}-j&R_4{m;Akt~`#Mo!rGg2>O2diFCw zs%$3U5>vd6OXc6)Z}|FY#Wy{#1X3IbT)NSv;O2NQ3Wn3bIr-4l*>Xa_Eb5y3ZZ$Arfmh7u zCQ!?;E)uf5PGmJ^9FZtxIwE8|*^pU1HUcJwEy@R#0 z^$#xIn!vQPeW!h%SIr73MRFxj*hh+)DiVuyR2mpsHqg%sVrmY7RhJ-%x0J{T22NHR zow?^_Mjg0gB;uuLK8X~_>s7(KEa}=as!y$Sb1q61yZbXb=eB?ii(YoCUC%O3I};Bu zK9>kdTE8u~9O)GnUD0=HFTZo#xDxgJ=4H`rlFpTigpR5twt52D)8pgudbGc#|HE$E zE8k=5jDCB)=FfI-(#@^Q*IusAe$&2Y(1Zg|M&b3K-lZeHUjx|lTW=fY4tpVI9zux~ zT@jq=%}4&vhF68*r+)^Gmn)giCc5@*tE+`84SwHTuCl@Evpzx{&c*C}E2vqWA7i#m zn^vD5vuN19;aSs zM|4mz*Khp#X!bzG{=%Zp0weA2;cbn_$w)&=Z79kV*|^Izwg$(}ZgswvUDQCdhFVYr z+9LFhpIWx}VAtVx8M2Dna4A{ia~t2)H*cnA)qJ}YKIK8SDX8Pr<(D`RVs}7Tr<~|( zbz;x7G>%1@qUjM{r7%cx^}sBwDZGrq^-5I;otqEXgcClk2s$?xxLa?7T_j7-CM?0T z2&e91jOXNv#g5j6(r1Y7Bu;}qo#WK(Obg&&Jrv=jLwjjd0W^Cx%lcsC_#)O}OEicu>_gDP+DWGp8&f*9hIOjywAc)6NvW z&IL774;Od1vwC*oYPpSQoLUVFB_FZbI973c~Q*KIkhheD2c;v?>11@~(U*;s=e zMiTe}mt9BOrZ~;B@RI7v=rU5T!O_#}c6_-RSN_|dKZo;k(Mf#NEQVlRuPiQ7v&%f* zule=4NaMg4@^vsu;R{U*p%a6Pms2U#3r6|NZL#Piu&JO))GI&-0l5E#7h?eJ6-?V8 z$98^+5;~+LJ_k~Z<)w=aV8>tapx^2ws%xsQti*Zjti86uBv#>Q;DR?JdrsZ#2r&mokyEclyA<9%kJo zV&8nR)LGMCm#jcwNz9<**%YTc-{QT251&ZXNcC7cyQv4K`MWEzPAKfRWXZo3hEI%? zA}o7)=9F?Fj^E0Lzb}O{?Hc?3U^?@t>6VpCG@UT$xi$V{k6LGh_$0V-+|sA78>4QyZ4Of{t(&^Wftlw&+q%g z5X73znww(1CCh*V2fKk~qM`*BNs=5WMM}ZbZG$S6^dMwzCGs@0QS$CaAt6nuL(0Si z!oAY!)DOJpcAEuv&Z62KC!Dn|L+FvTwe!o~Lvo*U0ozAmk+j-rq%M9T;ltTM!h9B_oY%Mk@;LH#BkaSX5 zdFAjJ#7=*8^E;?+=7|;(JR{3UIPe~U)>+(!>8eh>DNC_U5Idu@CZ?SR=0@WhMswt` z@l2zkdZb*}QENZDY&b;bRDrr_sn_D|n=I8NFL-NkP2%i>Nau}HyHYGqXvEnfqhLIG z-TGH1Yag6Yv=ZTD&iDAVwr-JHj&AhI&a6*(Ie!WnXX&d9y{plmBz2w7A3o|h@N*@7 zuFAXJZ0f8p#7vIcZUlG%IVlL|))8J6{*wr$7(|!&M7)+?pu5j)nx?kzxdBo*VU<-! z2%RK#yYq03xo8|X;1W7t>x=M`hN)Fn zxXB#;CP0+bm2$jRme`6pTIXJa3`rsLh#sYW7`RZ+d#s2_f-%H$xl4KxA_QuzwBHq( z%@BS(ztX}Y(X*dsj=c|q#S1~`H%7#}PW+lq>~f%Amuw?E zK!pE9-AU(;!610DMvAp8_AoD1wAoVY$<%(3lP>opq$NC$`OVOmp+k#J)Ntb0G4R;A zru0QiR=K;XM`t0hPstCG2pWo>ky+(^yMBqM%t(=wbr0uj{9GFov#p}f_WPPrKaDB8 z0wOz-)kxFdJWCh7@8Ld~A6)as90z0fep@#YEpUu8GkrcdKh;a~vlV(LjO}}O-^zA; z!6U>aDW}~UHg~6Q%gS>WoIw6zmLcGVg z&fT*9u;C??t)dx#t;~>#Xz(h@$!!Ml&kCO+CBLY!wbm4 zK@B>I()-_pM)(^)@j&>V2H2r6g*VmEHV8EwjIvZB_FY3wpcxjSb4C+IJxWUxa(f>( zSUI@*1)YlK2ezF}D!+^OWd#J%PUsG#91eXIVC-kG9xL?g2oQQjYc6AymGi|;@JKZw zA^xN2K`KL=R5BI?rP^T5J8=cbh(_a_0+Az2ok8&QZAW=C?7cgh1$pj|a`4(2$lh@XQeW znrv2o(y+e9w3~@!Jhy`K4P<&m)~<8c38tK^ z_`Ml&p>Vq&zFv)<{PYna&Zkwxyv)J2TQ0J=yJSYy-*mq?jMp$#vcD$Xdp${(>9a-9 zYl@bWlDnCZM2x$Z5vcm)$ZwJ zXhNj6Qf`k45M=ywBuRx@MA^}nRp>@TaZjq~N8)-A+;+1viOiW-zG7uETJ6oqNu?n3 z&GR_S)R}+q`o<$}qeGgNJBbKJ*7hql@rUXEXMh1>6CP#9#~xjA3EVyuP3+o~OFwP~ zIN26YzgqR)i{IPq8!xb!0wZNnn)mufmX)yQx7$)R4G&189sc^5mtrbPJ&nY>^2ekC z>Rf>ZEk5Jw@Droe*zqu{$sYXna`J13`8JfdImv`Pb3vY5#g zzalub*Gr29gA{aZJhrj@w*lEMSe0T%H<3az8Z9eRF%DXJ-Sv;C=l7boLdqnXr-0Ai z%^C+$-T2SBP^oLCq%xIpo-KpeyY&+kk;C9#S;cyw$tDn&uRQ0m$}u{ArTu-)?U%JT zzizD_8D$ACdN`)kWz2@EoF!RRbS<^dJ6K_mn3mv4MZ{Pt%HmRUnf4g`mnr3J#;u54Yyr13m zaRNUff`FIUA!%UX-E>dn5X*VgJ0D&;frC7`L9)I9GkL8SR1DVK8VhutV{P)V>y{9`;L?W}3P&lJ+_ApQ&EL<+b@1mrsM16a^OgLNn$unsyMlb2Q3Y zxNxc`H5Dbl(jOyypzqMAWtA5Z;W;#pPd?C)Xq5Y`pSqCQ06aN%@p0uR1P3s;|t zNjAQ%>uV)q;4h6r~L8W5XDlr#hT)!TgS^1tV%h`3Q+wOSI zk>}3Jcy&3Ba}FCHcnAv8bL7}vUZ3>izygUDT^EgxnNDB(W7Y?jF_^cjvY=Puf?xy7 zpH>TD3-0)hX#QM2&FLFyoXs2@fP~y$HApsji`g$L z47`1w8IM0?ws>Xh`HUbBfhy+ig}lg_7|Ru>_iHY zRYpwTH0C?ed1tIW5WJf6&Argc8*i+c{dnP`X>aQ^kgU;l-R}L{2c9$LX8k^NI*`bY z!g#;p<+5A@x9baeZk+7iWuZh2*LS5L(jR%P)01?(m6d6s=wx%E2aVGA;Nx^qJu zTS?n-7f`bj2fwM8-N|-u@xIF9Ve_8){OmN{lZN})z!3U+E`)t@$~S9@G-Zv^6H_O2 zw%Nd~VqK--l|(yU@(ru#@1hzrqK!PO-8io_PGhM#vjq=J-6snN=G^kR4$0*8de*Oo zuB?QXn+E$nkq5rdiV`ec?<3N&dDb9*@^wdfBWxF!_qDCw_TRybg>P>5zXF`{4m#hv z3LQLo_%p(?fZTQzr_SryZGZLs%f12Vh0h2GNk2q?msK1n4T|U(4mua*S@h=q39amJ zIiddLCzl)ZCjx^eI?LPldRvI=N|>x~E_}d@TYGXwk(sAd3gXT+6%F+uOkH%=G*27r zWElBM#(pJh5Kz(`W=$FGBs&^W=HJ!o7| zampG%dh2WiF&EU)-%MFo^z+)})MHOnl$4bGWXHaj2uZRa#taZcONtOzE7dBQ%vTt$ zFjIm}$#?z)2hv|aGL;O{GA;`4cLfw2vFoyD?1mqp+!irV*BOg`1HYT`?*ZHE_BnCt zoHWvr)^CMTBs22SN}4Y%xtuqLr07@gMFgo#mgx1j`lFJh-p4}-qVnHa5CU|R5*0UR zv&nYN7rEQo51(;%SeO00|E`Ok8m)jf!|EJ9C}vklsp0$h`0=TZU6V`IG(I?E{~(?W z#$|!uFHcLQ{1CW*2A?1~9_0`z+J4iy{%DKeT|vzD zTwR60`J7W>nA~$j{*nzM7lEaU*CkjS8NHiyW540R)>)yX4n|yYecbzZeOcw7io-7$VzUp^BPDV4N;H47G>>1T4jp&|^ddJWqsdsTwXH=hx0eK6RFsI6jcR#7cj9%@ z>m@1k(0)adSv?ZkWru^AeJ%EIKzBr07KW(Z?|TB?9k5lWptsO&r?R``o5YiJW%JyXvi)Owd3k1IV0f1yPGV6_8aO z<>9E3n1Dpqc+MK{tTk5!d_X7}%wxo7mah0QeVThFRxGSb0KJkBnMG@%Sck8s-eBe> z%GGW~6x3-zAj@u%xxC{sEYUcUW2dCS)`|e?O@%P60AMLZ{jaE~hN>|>lZ(uRCSl;y zB|d{B{ya6bl0d5KWowz!b)7aXkINlfZ8{l|OWrAMX|@T>I^&UB3S%l^PtbLgX*y0>vFCcp;iX`2V zZTihvSWCW5psxANn#H#9Na}or@9r9&tdb=K^?*V1tlfpFl(EoGQi-ARCDk*QQZNf_ zfe6EG?F1dORF^qBNhf>zzcMIW9S3z$fyH9uxvxg>&Ao`=V-h-SMV&vlWMs*N%{Xt3 zbTZei#l%Tbk)mnGwE_X|_w{j`6^+=jh@4FsD|YxDbrRWDaTh1$;>nTzWhROr@RF;2 z??PhH36o#^9c#LO^3aH2N!F-IG>8`U3R)42KRkDgfimgtr$s)seo4uJc*K)1{O;2E?xbDr2%Rgg4M@)qo4C6b8F3_?~Z;4x%Z)j zn||-YHfj(UkQX!Q1MqMit&+K35c6bs@HL@UhszV|+U1XN^x|(2iXwo>Bl191^+sQd zXl=(4+tqKrJ#V>#YMG^uH+gR{?E-bm*g{+IyYo4}@&v-vdylXuf#YtPTf5jm*;E#WTkcr4;eznMz8+e2{NT<@5QD`QA+M7UPvWB zF`%&%Q*94P=}r%$mDnD&PE&pyXu#70Jck^u0()<&+Ma>x=5j7pT=()VJW=$yx; z=~aH-Q;-Q(N$w92|JM!c&5xl=$k%4Cz`v%8$j!>Ee17@?^vl1I;?&1dGq)m}MQ)^^ zy+8C#@-GEMLP3~#-C-LR*{AgcgM}3Ll~88pR#T7+_XNOIE^hxDTFRkIv|kYz_KOYJWdW>>(X<{^kv~=hOLPzTI|gwY)qCiby#6G%wLc${3r`Uk z2NS5fZ~%=-kz_)G4i#rJR+E|x437}1`Vq81 z40(l)NQ(Q=Y|W|yKkA}L2FhHQ@U6IJL6G)&9(YTeK4Y=GqaTZ{aVc!3P=jr>$?II3 zjdvX(He5uO$?_7=x9>r%Jd?V{A0Iwj2`DN5MGVW1Rpbn3sn(=b!2sqt#_k`^THmef zGXAb4C{@Ufmpo)@qTR5X(U$6wFP5+9Jew+64xa$_U4 zlb{GZKkTN7O5_?89=-mv8Ul z4xy;;>D=yFNA(;3^868+W%>uvGA6arjWn+Tf@-C7QOH5ldC5(H+Dl+e3ILfaw_th4 zxZgj2P*)h)mT@dBZLZ(>*?CgL=^7Luo`&&|OmIxs=XAUTGfC3zH$z_HGqV(q!FQRv zV95SJl9+R~Z^2H7gm_bR&Ae-}oa|!G@VNy*Oz~MN%F$8aBnRbuw?m->HiSJ`(#)evefDitD4kzs@pb&H0)LPmZ6#410uxq2hKvs zAq&S#QE0DHan5;XqF6mlS^(jX|Cw>0LGC}CB`u@^2Yx*n*!4_PM^Wuc*lC*s@O9=o zXqDev_i7gR8T(hLYzp`CSMe*Zmb6G))60VUztWpALO2hLJP9<;abfdo-_~EypoPf+>YAh@ zQmpk(n5`gFN&Oo(af)${W{FrD}Quix$(XQpD4^Ns@h3z3dVwK*?8ule}*xu^| zJg&3o$8SbkkIi5r{o0YupuhY!0(0qTo&)w1-kR=rYk~0iaNx-r0@9sFWFTE+ZhacG zFIWIN$D#oY7NNIY@HyAxI%tF^aSiad7XiED(Nf0*%JKxUZ|ZI%8v*i9TY<3lUqujN z0FEUvC)ee?)$3>*{^8nK+=N=K;Tobc_RRn~&Ok?IA{onLg4?g%FoK?h8Vo0D3c zG969dYmX;u7@VVBkFY$>z}aH~lQX^J9fmY{-TNjZ53S}LTcZN30{xA?u>=Rq?RpJATB5D@)xaK-2oQ6O`c+ zy#|Z`{4TfX&x?|MFSit~&h{=Q6#8q3?-jj6 z0TiHNxxwjF=Yf%0x=dk;#8f5bSsSE4e^@4#uI_RR#s`uV393sJil4US=A)5BMwMA4 zs_0-GO&a(?Dg3U7`_F-rZl%-?69RX}9SL}HDiOHkU}ku{P#QMtkK1@Rz{vZ!N9B#3 zSCbunJ;TzOgnb9C3A~6`LIr1kN;>f3!a^q+K0s|7NXm!1Bcu@24sa4&-mM~V>2ivN zm-Uc(P;tFgdclSjVWl0Uu>{+zmqG6689UoHfA{}gqK;6mRi1~u`c*F!MhR!sd&uynY|{$H7{NP@EjSl zPh~P2F&A2h#6_%Fs?1yqh0rn!;uhKWG}esY#$4MeZD(v})!^F*e>Y9_AHA474I@^6 zR!|~?$2;y|CC?nBU~FF1U$)E``FXCg-|B-l-~SZOpwGw0JYv^}fjWBVDr!DX)Yb)N z8W!?F3OU&z8K~p0kb4neX|l6ExLBd-ucu5Rg(shJs*Za1hm=`}#^ve3-=9T3L63co`O>{n1V+#IOk~;9#xf`%2rv{WkC6qtZKrKd$$4RZbG z2nICO>Ee5Yx~@a#efStphLwckWJSr&P}A{mrjk9Trl?KSXeE);w_iIDka9rmv6Yk1 z2bhRnA_ynCm|L!oRsN_7)rTy49W=pi&KKR*#}A}Vb&P@u<_sEK_hes347B`N35Nm2 zcIoP6u8@Cy{>?1iXQKGy_cx4rzjso7aRx=sQ^bng3%$Z?D|4|rBQz&QREGwI;h(us zCvM8CG#n72Ngt7c_i~cu;nGh?f}X2IKcrCb07OM>1=2Y!b(MW5LZkqHv;g(!ACY5A zCCu&|v~W?qVQtc}%+Kk2iN&&pGk=Egx2M?Yda?9Mu7kCv33?Or8<13&3C)x>&1Pn8>!ZQv^j4-2krZH)anfRk1OVt3bxezXtnE(ZPje6RL40%YB{J00S+AoU)GxEd)m&?gMs6WGE!|}T_1scpdmNf_3<4YM#+RLBD@|>p9A|cwt&XosB_?IYK+b^-T)0pg##twy%V4S+9^MzSm`=9rED6z>A9vKGq89udb8C8Il zech!ntunY0!MCs}wO#LapwF8R!{s%)Pu%p3)8x}|ukj`>FI7bRv=TnA=L;+6EGi;Q z5|K?>d&!O92v0r=k2O3#S)xn&^H^{;ZB-H~W$iO18yq2sS3U2lgW%;?-%V>6?|@=L zCUl+p$LznAO?zyl_P5-gmX6sk_b5-1x-a>|K*SW;cDql*;Z1MzoGEY-0f6J%LK%ap)r(dRsl2gi~XOoJHXc1Ty% zbjbWmP`JE2cg#r-efp;J8uxYvUIuW@QXAKba#$ocQj8p_BjKi2uDl-PzDyG

AnN zyGfPE#ae6@UL&BpbI{9Rwq&ii_6__6_-uXI#c?m&JbDO)K+{@`TwkQn{-(;irY5kh zmHIBUq@6ZiGgAUS@C%bfZxX~rWjkfpK>Mf*!%h78rVLRhIrxG57GMifh#C579Hbza z&^K63YeZA)dGrj%LQ8%uieb_G#my6Axc7kAs%Tk}%32s2KMSF? zej6x>BQll(_Hk*MMs?ey;LipCF?vBU-~0SmEZkwtKWn%L#FJc@Oh;KB#663SKt8AX zg+xM8iev;y4U~|2oa%Tr`q&vQc(ZeqP5&@42l~N1a|2A?kzf)m*wMkpB}Y`=vKAS$reG~eh~6D|49;ONp^>R^T?B#N9;>`ZGwr# zfr&9#KiB1<&jW-!pTH6%kEH1|eg3ZR$jLE^)eZb)d{Uku-SgVEi>LK;H@dtO7{IK@ z7A>9CJ1N(h$Q?bmPDEJW zQ{rWcL8bD|-ioA0aG)xSn9>yf*rt7Tw#BGsOo;P^AuiFK#85$T(jZ^3klZNMxrp~< zU0ujxoti^g$l^A-@AX@QsvH=V`H6(Q33X=mWC4R{^9Z~Iq?9$i@-)Iqol5XLO1Np} z?;evfo#e^nJ(`SClAf&C{x(cuS4}faJ6btpL7QUlXm6}A97DrvG&8dZ^e;8>Xk)yv zG@)Ph6uvygn<^gPCY3W@l}!LY@sg&cdm3Up7va(F1KoNUU}z z`)*NpMOk!yqP%@0mPY*8f&RJPJPj=h4`vE!v8wPqJx_gsS0jvI(<750N0?^b%Y|+D zw`=`|dA7l1{LI^PvKIEhG!E@+39R(Zlr+8i8oC2$Js-no^|cr+DEQ5nCy=hRNd(&( zyfU)@tlBud#9Re^J?F=cXny^>_k^3H<2PCdNV3wCk+qv_jT;)A@3TCNIHSMz#O_Zs zO3xg0Y!nuk#-6pip;gtW&*SX4)iU<>7;~CtU(6Mt_5sfhA^i3A3=0noO#{IK+cdKFMC+^~!^RVf(L9F4c zNoo_-m=?*f;8j-WK(B!>CdKXaw?08H)Tm&%#dLVfKneRhGr#U~e$i$h`&#$kavQsh zrY>i>I&3Un#syxR-$Hbs3gtHrG@DFfO_@mIAtB+OA^tCbti}QU)z5-VPv3h?CrdPy zF{bPFzaSiF9+Fz#Wqj%4>M?1xgZAxH# zo5^X@Amfd_0`vT>(A3lv9W#pW<%gohfTkX}AWk4G=gJf@?I^Nc0`FrNRNw?^YM&Vy znAz33oY8()^e*lWy|`*D60uFyyU86WVJ7Mw#GHQ7NPxr9@)_9Kby)6Ub2?@h5r z#ZxYm*#y5&+ja!2*xX$#ttjjOr!65G$fK~0opaF(y8S(wmtXue_*Q+YijDWeGgM+wj0snXXdFs7*VN{H>0i-9lwqG zPqyn_cGUB((o0(wxB0B37q@p+r&uG?9$=P=RpqQO6zyf$57rZV_EaisYdjo`v}F9g z-~{yj4d6WN37!lODSlOJWDk^(e)U|1i;=dB_uhbm4dnyrwby}n4p)a3IwFZhTK^Aw zZ{b$;6TOQnAdQp)(jeUun=V0V5Rfjnba%I;BHgVZjkJVx2+|Fk?(S|lv%j8u&+q;V z_c_mlk8btznVB_f*37)`yVh1d&6dZ=b_5C6U)CJK0rN*EBfXVXm?-92M4MpFup5%Y@a>+Gtm{mX>;-&$q-bwXM)xtOI#)Bw2D; z&%Qyi<>H3HCAAT)m6ejouJL#Y%`?4_dk?F^vhr-SVV)m5lM&bBz3m(Ev)jgO4eNJd z)z}&|(Cptq&Sy6+FRqE$=XR`=+$}UIq;#B<-1RLa*ll*HF_m?1_R^6LQeCF@JjjBe zBGWb5=p`#1HDT5FFJ1(*(p#Of@NIqz$f^iBYP(xKVeeCIqn|2x(W$UcZ%`JWpkJZ3 z0oWcxG+M!r1eO#zxFTUrwo3RB0`yDYE?tm*wB8oGtFdH)ycTDRoN@Xqd1d-H+O>yi zdT%(G5|Ly!H7rEC8|#9?r&|V&KF9xIGXqHU(Q3!De*J=9iw%t00LqywB<$ui=fO-C z+-g@PR!rQAX|(_SA?3qFsX;TtmtZGg z-;Hx#RzC7}?9`X|UNe(eFajWxgW$xUwRY>sB2n^K-162G6Z>&Mfcu5hfm7~qYl_5k z_XfYvaQcG5=+jxG?`=C4*$ny1?BDM6I?fN)k9`$&yeT%OEla}{3mUZV-{;~GM^raL zZSwiiKe+Dp50#&9a2K=OFTJagCoxi|o**8XK!4pN@+Fa5fl-Yf%il8HJK3o~fv1Dj z5jFYOU#nKZU&(FV>IQ40QcsOv=PX?hHBfWQneCF2BqQXrajKPJGbH|+R$Mz~9C|A*X*}z5Oa$8{sg(_?oup!{kX8)#nS^T`q1GT z?*>bARqkYi9vbfcEs+@RKQb5^+i3)(GCoBTP-8Y@&$hW`wChI&@SGr$q-fl z%P$)L$_NyXyfT&A`uhCuehTVJ(4a%|M#v!P(S($@Gi#xOb2`vcem*VlBHwWs9(ZaOu|~Vnm+I9)I2JP$|l}&Z)V3M zMrqyA35nlfEMK7h?1A%R_GiC^lNCk9tI_;l6WYC4mCR`mvHYnX&*L*5Vr4=vCLvQK z#!saCzP8`z@3hTJ<``N>zdU)Anb1fj>CPap9cj+lY!s>H;3V-_u}|svgMjX;X6rlx;9;(XV9> z*A8*NGafG23|T8bG2iBXwzre3|LW#Z0WUS(Lm6f`&zqC2kVXRwM_+xNv(TH00>Z&B z>q!p)LESFC{F;7p)Q(G4&!lZZM4>*DKvZ7N?xK99h8<45teqjI{t`k=JAqp54gEZ= z)!6lYOI20MimmB#nZa65g3}wlbjIQ4x9z6>nyz!#I>IJ zD1(t_3hrh|+84|eyk0H^=|)etd$Y)W>s3siO>~u$y>xc5QGBL&;hmh_ZLs(I4}p%P zkF4AU#Z4P|PWDD-U$2+Q^mg5c=JDr2%G#WnSW+71aU^ZzbhpriJ z)^VWjvjLZ?KN+#t3I%=WmIjB}LdGJdf5cftg?rCP(RQ-j6ke1od)-Yv?O?Et=kiLR zKe$u(`qPu+(6c)1a@{$=Jm)Ydndw?<{TY`ec{aq_>97@r>!a%OaLzpZ(8fS(wacr& zefPJG;+-3Q^qu?VB5J$2fvEOFdztWhxswDmwrccz3r7Blp=ktdL-~@!Bb3oPGjeY# zbZw?EdS?iKug||br+BATWrqEiSrp-iky}9glYKQXub`_rGWG5cmsFgaNC>4MBD38vthS5lSrL>nD{7yVE9dtFvkl181Y z>M`|6Do3l4Ouc`AtO7n{doSwvLbPO6o$zO!g3)hK#W46w{Tvm>GvgA!wwg!zp!gM1+R6c@Bd? zzvnSD##y#Ho_xr{GF)=2m6WaLg9b`rW$)x$^DFMrYF1XO7|2RRtoop(wpPrBg*Z%; z>vO)hlb&=fe05C#gJ;ljhFm;a@E>^(&E|K4{@#{R(BD~$;ISR*nl!T_s?G@tUoY4+ z=Gq%fS?%b-ODKxDTMZ4*l-6gm;ky_}36JmBxgZLk^|T@8IoS$!w5mE7q_0`(buXx( z6+DPZSCXxK)t38SM7FEsEy1`{;{nuNLY%r>y#$f5VI!oC?y3PCf7=>vn+PzEy?DC* zW==wk@-{p-6K9pQL(GP=9M&$5bhKe+M&px8Gb$&az1-WZft@W~h?tuN8gU_8gKXbHy8%Hyo}+10c9ODsa?|IgzZA z%=H$#Ud{R`LYc$f*K2MgdyJ?mV8GTadlG$DwLa%YIAB?~6io7UFaAW|CL+rFk!07B zVoWyp-O6>U7&@dm+OJvg<`SfPvl=NtpoA3+>y6k->+FJUvNBpOiFLXs8xW8DrIz3!C z8Wb-|r(6DTr$~uiPGA4F*+lW1LzLN}XP}%hK5}H#Ip?a>VX1*&ru#*|cueNL)_IOW zeF57lF#quE+d*kuo{fsM(X)~(LkEwiQ&0Uu4gV@SLQge%v>YvX^O({qi>~rL-fF%g zNyR759ggFNh@?9VI`lsoN&9B^G@?YXe|Utn&cRWwM;PCGeyN&v9cGqMxgPHBdcBob z?;7Z^b`G7Hh3FmFK`ar>UqaOL37Un`5A8cF9R&Fd!!^)<*JP4kPt2 zm_(%b)bE6sweiQLzz=J=In1+nC+3&dv{v3kQ!+QVvgNT+(chxWWVk%bilTCSJjL2x zFGwA3-B@2nS%S2uhTSj1&eeHk&?75*(wJo;cie*Sa_{64Zcw#C7Dy(U9xA*TK^l7yYu; z9`@&3!LKDQyyxWF-)MQ&stlqZlv*dW(cK!oX0UQHz@CDsEoJ=}w$Hjr^?2!xGsz^)AI8d!qveZ>B>RY|)xH9Fb-F(Gv+ucn)@skrTvfFBc zKm`XVUH=}S+C=N`u^&Bpc(9`BYX5(#A?~H4HK@0fbW!N2#5~KYLf?DyI;bN4=Nr!{ z+3Jd5nxh)_Hwi9#R7{ezc&xH2S$c0$@u=U(Ozr#n#PYk&Uo;&w9yD$=9;7=C+KOa? zqAiXl>wQ-u8^WeOrY0GVVkzitQ5#5bjS-;Jd{?VyY+?brt~a^Sa58W*VW?)e3oCa3 zG>m7x$iuB8qiXKgn)LK|d__L-^Szn8O2E|^Ojlc{Qr#45RZ=fEU#*kyFNeP5{80~N zDn>voAkgG1Hekzd2CUd#YUw~?K60p}RFd{izmJ>G5O6maR?Kx@GG z1cM+BNX>*yqRVNWr?ciX!3TU&lEQ|3%TAdo3@fiSkTGO8uzYE-YG|GR+JRtp`+}@zL@hf&ESr+c+r- zZ|;x2r=6EaD{BBCJIex0AO@q}@2Z;cg?+27rY$+C*(0R%$(_U{JF|(xGl_P z=$n7T*@Vugod7CVwB6Y{)%)um_A7v=(FdSlPoM|_aqJoxQaqgb84Xy`eszHZqnPw3 zW2;?6(5kjdj`+QnptIVFLQ&wFhH)+yM3< z5$FxvZkB(3W6Q^H160_?olE&0m@>Kk6rS=zM4uTlK^N{+89ksVR1&?8$#|O z{o7@)Fm~Q)RN?wQI9#(i)otz17p2p*k8A1tj=4>Az@>=G>%DKzj{yT2Quto7JAVNxI>dsY2T`g;WdQ!a?-)9pH)V`m^ zIHU4Qx=_h;+otM8)Z zg34O`saQ~+lID{4w5Bh-bYP2Dru*e?)!(Q{ z4_=t{e5sKS)N5!6?UG|?7-|UXsbPt}Y@5-N1+ll9VKoT`3j9 zXrp*O^S!vIn#95FgLu{VE|gZEzd5q7r0c9!i)Md`QZix-I#@X51}kTZqESA#%pGWq z8+eoXp8D{*_4&U@*w~r4?NNT6eu&)cEq-_kfHcx5w1qz>4~#BrjWvoi~0B~;qp;9m~uxiKnQPMi@_vO zPQ7mLZw`Lb(|Z^UW(dC|c6D>qEqu?I-skgoEul=@u$vsgWIKckpD!29%?~P_f=1-w z_uUe;mT{($o-VvmKDf;P?ud?0`*0>JOc(+=o7LiNLvAnTnC(GoP!4JNdRFnMx?M+= zdq}<|sshdwTWuR>wIP3ox^jQp1@jc}`#TCDJA-^JkU0DRdVLeFk7!hZ-^JIhd0bw{ ziJ473w*B==cMypb@e^uz@}?iTE@oa;NS@?{Umc4ThfyCUNqZd>wgmuY9PgBQmj+ME zD$wf}X|Dod`&df3Z z?gQm%-#cV#JB1bYiC+n*g7rgggQ1%na53~MKqbeuRH>|dZjYqIZE=#O9}nuBB4=wn zr@H2kq*?%>xHOU0L$j)8r8(XRjpUSW$tNTcMh6>oK8F!8Da$!iaq)?X38#(3Q7fsg zu#Ey}D|6ds%sw>oB9Hisxop0hdxXGtKKGdkorK3(i=668a-}FZHxb$Sy{(vGu4*_gdphwK0xkuUF1b^_{8;b(;f=WpW4TiDeA{0Z<0E6? z)L9~h(3so~sHDI+Gk!F(3kReXZ_VdyTm#p9EL=#TF@0 z(sDH1=7$mQug8D4Imf+;qm)x^?;9D@>EJ#~=mmMk==KnL*susM&$-~oW@1(Has#x! zP;1=~7x!%_UOC0??wy9NeNfX|wU?aMf1{|(WutP{O7(@Qu|_6=7CDO)#pnL234L3< zGIM*Lze8uAJfF>tp&#(!QY(j(Z8`72CCu%UVOYvsL?c1QX0 z+>AW=Yz>oI;p$qe_md0MaYV9KggSo6}pJv}qYYvl>{{N-)U2*p>9 zNMU}V96@}Z1o6H;4drG~ZNyP1(*9+UVj*b?3%UE8fj?4M_e!8~J9>1ovVhCyW_Zx( z5OTBUf|!y0SvODGa0Yb-QnP7Vmtvbc^Vots)>W5O^tz_F!=c0H>=Rd(E)NR-@>IBG zzt7X6D|g(j@!nsd$9D4dddlBl2XU9ZbF4YVYgRpLdn;{AX5$H)n_C8u+?bo421F7&?%KcNjoM6IilLPo8B2{0b*gR6=N;63 zR#1~`zHOaULlI8RZGp+ll0EhPvEf40UX>;auPO!UPMYI@jH<0ISU~pda^@u+ixnH4_g6Ow# zBZpG9sppXMBg3C7Ft-oM!JvcyqUeK{!IPI(ExP?^y>?{n`n4H{f?;Q=zjWK6te?#_^YXyqEYy*G{CT;B6o0Q5K?COLv@c z0xZwt2Upw+RxA1I;)L3osjv82gWxsE7B(u9?WAXIjYt;)+`D6Tjojl!8|mzl=1zLc zXjrJsFk;_Y3i=T~*EcY+DzTxrGiVpCLN#^+=NN3UY}&AIX`6&dIIrEhLmXQ0*-Pbp zINA=lQtYXB<;lld<3cQ-e?-{BX>j5>(rha^=W4Uzg>6-6Yd9)4UYpQa9U=^}VOY~N z>;~nRt&~5dG8{mW3*Ff?WuC@$OAbyn{ZQ!P+#m$(Gu##VD}2GoX(q6Z?mRoTjSi9+ z*L95xu^7=wA+9Go@h99taR9<vQIveP@`a>w-fZ5K?BISJ)0XN^naK=#@XScl}nHh*sM5tyWnNG78cPPQKlT5 zS!v{#ddpnefTzr^Dm5OSVeZqg2QiOjv>HkZ#8Q|#=Ufv##~~jgfPBE7VPf}7*2h>E zc$3XknUS=MPD1Jn1ZMN)j!ahXo)Vk5v`>%m&#Pdy_6^Ng8iZ+vd{AkJ;QvNhv(CdoJ=wea+!$<#D=6l`R!Hp~jiPj`x$I z)BB@W^>3=TU)R)mX_dnrlx@wcE|N7J?Z#$MTKU|N#y3OlcB~>I`I(Yv!u&WVf9ep9 z_I}xZdp0XBZcCrh%MxkYDeM!yfsxPn+@v>hTnH6@L1a;>60;BL& z+=em}oscr;XBKAoe%g6-qRvr0`}FWUn;hZy01>+t3d{v0A-KFx*s)-U%Kz@HFW{Nx zd#X#+$JiqDq?YEFR!UzPZzr|ktZVV313o+VFn;#o4M3RMxqsouN0r6&*w276*FIEj zw105mpd%wuvicjaqk^S1Jn(pogRn6$`%zzw$}*V@CsKVMrr?X{X9y+{;d(^tj}|YG z1ZN=J<{#v0p}x{}WkIYfPxzj_lgK;W?@L3G+~9Q_uShXOoV=)|HzD94#BL^;_2j}J z>cZ}#(=>U$NKCd7@wvZtA{%kG-OvYt^yy*^KJ#MTrv;9E#woPR#t$}1E}w|m=*a|} z4lDA>V^Lju=Oc)iljxCn=NJ%A@OS;Lyxc^fc)~&?A8oS87Nc6n)X-8<38(mMIa=@r zh!=zKsHPQ~7zZ;916CNIKN^dY?=+_!J>F5c1y%)YKR%3zoeJ~@ZP$5%qpC38NtB(B ztmI66Raf_XqbJ6CI^(P23xBG0hd=0c-#*>2Wz3wPAt{+(dbi4;G;F_MH+Fde_KM?0 z_i$4rpUGSWXkyR;cLuJVffdS_9u`$~HlB9Z?MqdHfjz(%93M|PLf;Wqz2T4|*vdl8 zxVrR0F5#kW$a3AW?!ygIrJk+d^)uxZx)BD+X#&KP91Zu$kFcH3Z4r+y_Y_kh{WQaJ zef%9<{XQ;BUSo27p-v`<&7K0-9NZ&`&mdbY$4>pIsYdS+!lh<)55rOAQ@XGTM1_!t z@i~VNyTTE8cih6~iZz$SG3~SF?Y2mWI>glo(<4q%n459Puxn=J#`fJ^#i07}L#8MC2iTlisolw7nt%{s!wsn#|pN#Rq zC?$#3T&@XRpd&xT;jMdahgJBUdHRXtDaNNXa{OkLS%br6#BlRl#qreT&;uptF{BAU zC=sV~OUk6HU04iDXWZ9}W62d+IsSQ|cP;WE78Un2rP=K~p@YMMb2R=ms+|+! zL7H1`mm@-y+FUCld&Oa@B!6wjGS6xy6LaO=rQV9Gp9|{#N$tc@s^aO3Xss!qi>28m zg=c5&cgP>v=EFKS4VM}g3ox&acEw0A+>ee~$NfR1wMS2d>8g;Y%0pw1drth&aQ(vV zRXYL%N`quxKYuDijh;Ho>txbKcM+LUBjxAz=?1n*E<1)EuIkm**|6!S!E>WvJ3k@x zK$?Cre`_e0AtUe@J=KZeoH!_4;U)GYqt!8v5Bk$|gL&fIM`}IT;^6Bz{%0cPC z!NbHgvQ>~Rr+4B&PYvX_l6~d^MRZGw&85rhHREx8D$$#T7>o#YlT!@odYjm_CylWC z>+@>s&qe%iWuL7j{tZ?R8tbsiC`h?Lkc;UY@l+BT`_gKv2mBU+;b_a@{vraIV-S;F zm;>c;tlyHKAC)U=lD;D*;<=zLy{pAv6&XrTBnGRGCs}2m`)Sw8pFtRi_p8=tGhdU>vC zQ_DZ_n?PP1p_@t!nD`}l4;y1-PMHk{v-qa`G-rSDdd2jqgDnqda?H!_{vxJzz(#- z`7}P}T&Mq@nfV_k8yrWxKbthTu7B=C995JQtHKwgpu%CIjT)qo>sHJ42T9&mU|sm* z{YykNq-c19MauujRN!Ia1HmBl=XkfTYUSV!S_5+SUxO`oA|*0>*e$nG$atvKU?ed0 z?0=Ju14*(Ph!RCz^h7J8@3eCyN|}MMDrW`GJnuvKCGiq3Tm~7>F6dEk_EJhmm%x?x zD~;kd52KJrfmo$;UppAAI60x1XqVJwc1y3pS4R}RFV=n1+$ZBvo)LsF3i*2$@LFS}LzF!$Seq}p1C zQNN*9K2t=vIg@QL5=q(*2|@0gL9-91%`D#*D7HW@LJGr&{sGSl0XHW{FNyN=MR1c| zhQgCSdf>Ur&0-1vHA)l3jI=(O$qrA3eE(Bht@G=PSmv$|-$8#0XvLth+Veu%BJ@Lz zo~~CQZR%IPe=QVP3H;c_K5EDsFEua#sS*^)uMC5qF{}6Wf2mLhPojavE<^MjKSpc< z322*jTY0T=71No(`L9m^Q5Dz;{H(rs>{b>?-wQx)ZFrJ}P!S5YJg^ig=y3PaUlnOF z^%7miNnOYAIA#sSdAAiTJgR>Ur#&J?0coD^J*DeGG7f1eYmpRq?m3vAVpMxkG#cqe zkR}ofQdHDacr59#g$~6&)g^s}8%dh76cxah(l$+3CIgk-%!r4zG*L#NK^f$pAeTSF6ZBd==tKl~lPY*Dd3i|@Pde>U zM<2MzA_?=xGpWf6y;KF>^4|dcLF!}==!4Pa;gSlV0RHz77c(CPh5H7x_#+u@45T}> zJ|UED{q)3mzQLX5HQWzqaBvVJh0l{3Kz$B4DIo_40R$CG^;pm<0T`v`dm<@>DVh(R z=99%*s&Bs%MfcCjEB^Onlm-%=Vp@J<7LWDxdyvrH+E9obDF>{B1Rtc|Uux+VucS)g zWh0=}2*N*mqN4hPiIE0S@(u?$eq^2PC$Vz#Wrh~a@fg9eu_44wUP!(O{iGbrZJ3-WBp^>Kt8 z;ZB1}=`Y7tCs}PhLkH3!BjT9UnEXszn85q|9tB~QBH~FSz%!7g;Y>|3#5#r4vG0`( zL-d<}F7xBdqbB2kx|qA0i^R$S0cN$3HG43tG4i0F-C|n0m#Dk*jXJ~-D+W*rB&jc5 zkk{*xTjzfkwL^#$y{`qCy)y80S97q6S_Xs1>HQD!zLlYs?zykaQ2QNFEDE9m>^u%6 z@X{B&Cy@AOQYHj&Qcrk{i2N6%wa@~P(X;Sr?+WMn^Ck8|94tDK=N4Fnt`)^ zEJ)g+uOf9@{?D>T2!}Rk?1_~o{V~*Uz93z_*L1~0Wd)B7rDV`&upq#REWk1CESd3G zj7h7PvxNLxi9tw1D^13|1bgRKf%uBjOVREOc$EG|V84_hkV(MTMqt&cjsrk$H^>;G zTI-3&U`5H``!B{Ow*c&y6|AS2SB9O^rDR?(3UOk;0Skl$9(Jg^9bU_U3JS;- zATubL*EZuBizdha7%6-SBy*WsT-*U;=tn69In(+|5oi`uLGZB+WTEUsg0Dh2Ap>7zoYvD- z9NCL?aEB24hK5PRA@(+s98_0u2-5|5iT)yl>KRCF@O`K_VZ)t1s0HkLIp(9e@Fbz$ z)$Z6>6*`Q6;Y3;(o>~3(aH)L_}Ip=4oBoF%6D7coOu^{vus zs^S=MF?2cd1ph*2ln27gw_l?`yxEy2KAq+O%M&RQ_`{)9C$XU9hn3FoX-~zg<27TD zNd0vDtJ=T5{(b5n{Zgt2%=izGwlea|a&p(mSR2?+3LLKZKBB6>OhSTK!6*T@M1Y?9 zVLI~He}@DKzqZb(#TcYd@KZ}|wP8OQC74J*T+JtN(@P@&=cf@f!ljAo0*mh$_DIO9 z48%pG8J}WdxEm6I(F7wU(%`_4psl~iEcYKhMkhh}+QI}6b7FX)ZJNi_VU+$tbYKJS zLS9@7Pyh|V&JFkAL8S%fTiGj`S{naFxF3}4X*#07d;0-N-5+km7%}jF(Qveo&{ki8 zjtD^>VN+Pa8>*(TRDwHWFmM2>R$4S9_;Jq8x+_HxJkk01Wf?krJ^!Dbf-(|xdbRwq z+vNwaougIXX?ntk#{tHKiN+>FSquK~$x?7)_Az+-QzC!0|J;rS^iBprl0Y269Uhh= zv0BH7JS#2viM_x6#i(zcJ1dMP#FI>uj~jdYD$JO6Lh=V zQ^Ke6mN%Kx!f{?BkOvG0mDQh)OipfX^h@yh?a6fqqm`F7uobry z-!u%wSk8}C|v1&xNSX6JoB0*dJ0VWC$Ys1+DhYbxeIr- zoU)^Lt5fJf3K`!w{QrgbS@=wNP5b}vj`;z%uphL02HBqqu+>69W@lYs!aCa$(g*94)2^+@}_5j!u&4^6h_{;~RM6EEj1|xF-Y}E=|b-|5(Ni zIwtrIc8Tq@n1lTnfZ7sHEqhCYLjXS7Xr{Dv=8=KEAQ-vep)vrczQB}%u4E`^ zzQjYt03Uf-6u_K61$}u8tb!B=Jkc{a8}yd07#A%t2z(?-$SpfEM*)3#l$?%)00K^@ zaJw?-E&fC(QX4RNCzhNl?6L|G^u@1F5#cf1i!zo#_bSmWB@v*&yHESyCsrs%Nqt{ruO4WOJM*EM> znLp-wn|8WbE7rpJRSM6i93;Cr-L+elLWG&ZI!}k48PtlJsz?ugGA8ITQSv|_>Ujov zVSNllK;1V7O3_#(6W!I;qMM$eOc7x;`g10kwVv_SKCT_P)=weEeMHMiIdX)(pvW1I zf7Y8Y*`M0ZujK>sGC<@@m6Tbt5ddM#tDVh??!t_9XT2eo_RmW>jkumfUB)~E??_T7 zX@8c{`iu`73C^6qoq1$#E-3gHTY!RJgFx={7qye(>5~u}b>zcek@d+s3672zF%ZI0sQI5Jo$nK|Z6A1YFfxnEqY!rjuP-rd4=PnaE5R z8S^4?VRQVhj}7~;)}-j7b)H918{K%v8=!R`6e@bD0^6kzBu@cJLzKR^<@C%(9uMGP z&VF}|CBtA%gr^J(Z0+e5l|l!izZsPMVtJ?`hVbV_-_MIgO5ohS7J z`vf^iA?au4iDO<_H-Ej+`j4_B89;dH!AQMT6zY!G-r!35>fQyhc<#_@9S!^qy?km& z@V7iKjTo1(3ahVFi`Vo^T+xDL&qO1oQjrog*5h`!Tt@h$ID*IpT4}qW&wqCsYrHeT zO3u`m^Fr`k0TjUbDq&2>l0?sCs+g$XNPafr_$II|SVLS9Y`d$0+Q%ngOEr>(#@3a+ zax)e7%wtLB7_#cAa}{}+S6$yfv;IXC?5+2gYc#w;3X1t_HW_6YsgDseqC?1kPX_2_ zcNTC$+v-)aI$r6MYE;nSs6$UcwtkPYRD4vxQvn7`Y58b>Fx$v9?s(#0{}&8W_}I3- zVXOYPmu%G2%;MKRQ~17*2-}@O^9<$81Y5Ls^Ip_TB|hZ{CfAB*D&!N(L11w)d%D_8 z)?uvpTc|X=e$Ib6#DFbk@p11Q%b**(h@hM52}ZB- z8gs9*u~G~owg^r8((VdOaL|L~yf9BoYl-@Sj1l`tdJM!m?wcZ&sm_Ejcm#W(3!}cSr&zxWz-_Xm4y-Hyx z73vT$Ri$uLgasp_x;08%UK}*9-mGCbX$8rV(lel>op{2}L~T$4h@k6!lz=l6qpiMR z6?AdSYHI{brQz~^p}@fM`*6M06y8*Wd-i$0`Y(&KT4Ue4Gm8VeH(yApXypn$9q6Dd zooC0B1~(xCWS+bB4js5}40GqpR|vJ5ygn1Zz4-OPY)*zgo!Yo3Zm^1~$@@mB!gfJK zPahEPPX_RC&iC&mMDCn2yl(c8`R^}B9F{-R%Um7PZ+mN@k#NjcZn%P?p`X5pqot!f zCAOOfSMubR9o=6RJU?{#ot4xEjCcK305C$WRk_X#G?>NPGDTSlTAtgnUoJw$bcpB=CNogHJ7%C5X`u#ZP*w|Ebj z67^}rDM)c;41|fANJYg6Fn#4Q0{*5`hft`>4m38Fra6fhZR%Al#a^uL?e{y~8Y_h- zj`Tb^8M~RbbnSClMtmwH-Ba}4kWf_UEk=M6vM8k;k{`dgi(it(ZHpa$lZmjP_PUU3 zjn~n27_L36*7*D>|7uyA{_R(Ko00Z|rvj_*wm6N`Z|iP&lSe9l-rn{*u!n0E3@>}k z1;mTnB_mm&iSyh{?N^v|24bEhP|815$9@({8`#&7bys2k>zj>EqBm2IT8yIYJv8F| zT9{xDe~mEhlQ06_;rWp?eCnPtVS^OWn`(LAs<7frLAClT2-+)_gD>}^eI`wMD&Ryq z&4H0B;!U0aejR;vm~+-!+seD~oZ;64HGU@7d>?kclZ*}GNfdr%nrZ{vlC5xw#iq4dQ*?-sIq$J1ZR-~4`e zxopu1dy4!Zxf#9z(q3u+wG0gdZE8#i#Xd9dyj#w>$9dj0w?CSO{*+dRuTRo^pDb+F z$CJ?gY+PB}&--f4L#@;p^l{zd$i=LDq~GPaMRzNht9fT=N4Q$Q%`rmGuer#2ZGwNL+I-Y6g;ErEKx-#Yd` z;;*q%Z4Sv9uIuV~m^;4UT34`b)@sF(f`M<|!CnP!{A$<(?eS#ok@4}ED!Xif3cchm zMlbUJIqau)1opcc48)`HrXLKKxQ@0vyv@65*qfVaioLjZwMM2|rY%$NNgtPF^Zb}b zsub(3MS_q0Le%{?OuT5jD zJ^+8?6e^MCH}Lz^Fz^SL zJ+@eEQ7TM?P2?H?;Y>A zxs?-GZ&a%g@qBm5G5=!u&w`sU(Pj>)T8VU~a@mw$@M_oddz^M~+JOT-+l7!ZWMdP&YjQ$|f0 z0SW}t5*m=YWBZ=;PyUdK)S;<#6nhnfK~eGttK@Up6ZoA@@!y>e4V7e^i(_jbx8i7Y zi+nHPZei5+4%dH44c|sUshTbQV6O{>T`7O`9rnuf(cWamRwecsikQNul%F;UC;RTL zR&P&XZqX>DlsOh-u`kD?O8di1JcNno?;q6B$?fCbo7sKdw&&NK@I5Zsy@pmM{xFIc z81ebxfm&FBLlEhy&yShJLZnjdeaFPEXVTV5)5cM{qUIg((<|jVe+&8QZG6DNVv?VJ zgc&^&5hoalYjwf*K4HN2=kUUL z`RHnwGq?d46PrL)YxWsELJ5jLAqx<1`yfbyqDzPk)Eqy4%Eb2m`1mLGG^z+(2x z_F-zqw~I_T$MvA=46LgRCZNmA-bAEBneMEvrbmvKwej(Elh=v-mV|Tl ziJFP4ElK+hCEh!f)6>38Q4Q=tPPCo`)>4m;32#qlfpF@F#6cS<%RSsab(o&XY24FY zf8b-~Vk!qqlE-llCow4WGz8fa3?>KYBkF}tU(#IX-=xf~cH2t2l_HATRkhE#x~jysQz;pcD@a@g-jxhg7(;AC$ao@DF5Q^%; z3WF~UD@!3a`L@J!&y_ou|70?L9~GnIR^YqYeQSZLqafUO(9145*}l`)1h^H31Fx{t zSMs_<32-za`5&6V0#z*tzJ6!yQQEd}b02k(;6rWDG+_YgTX>+ut$o#iAxuTrQt&7Z zBkA2nVf|V$3Hewf8{PioPm;Apl+p=Dey6nG{550(y(CN~7qAba#1A-1IJk7hnY;>T zUR5LEw)h$a${2O4;1{6gXORV=A2G-#?q_A?kB`;9zxzd^VHdV+yVXUa8*%al(<{ioj zYX;XqK06{x1Rka}*JDT146kGH@%AX6$cCfNnbo7NOfDH3Ae$xyX%>o~t9qS@`@+Q| zLxoG}1%)9DNY0*jX9fDUXu&CSz7Oe~4yzwZ$u+kYe1E`%Uofk)sGa4PZrptEjb|EP zTZv5kFpjYa{dCzr)J85$e(Lcj>O+5uS`Tu+kUffcxk-PdzHrp~_0CN151Fk@4I&Q{X`|}yEnJOQ)S(e6f*J_4R17m{w~(T zaO`)=B#G)-h2?4%%6X(?^$CVf*(z0f$PZU zN=1gFfjUwlDL2Q(Q+?;hg+K%KeRIKaZvvLX58EKE_{Bp(TuyPzk!@P z>D7xlBurb2$$g8;f<%)Nbjabk@A>ZOjX^4eut+}-+erYkGKFhH&UU#uqUv7!!*>+4 zC6GxpPTjK`K>$e#bJ+QLku>)zaS9iLqrFL18H9@ts$3eV9mv}A#M?jUc7g(TZ$6iu zDkHDA;D||tH0ASB{cgHDufhDd2ucs`Q0db}HhNvm)vTphYI`W3M+rzy6l*DBH@ABT zwTAJ(M?}drV7HvKQJo+D@-j^5W_Lwf=p&gTI!H4ixk!~_Z-PmYyRVASj6X)`<}>23 zzjBuWfS~g~K#)!aW)yr9y3(_KMR++Mj%H~hA7|buxWDkek%PDKA^k&yRKtfsd;af= zFU@)Eek-QLgRGXRa7TGRsZvEJF(uUp$1fhkoALSff3W5u>JA@KrQHPAUV(rP9Y7I`u3!&ZPjqaoy0ADW!3XG62n6u=P-es;)Z)ED{LT!y-72lcV}EsV)KK@Avb!RE> z0rE`qvCY@XeD)8$1)CKdqR1JuILqmX>m@D8{%A!Sz7(UM{ibXa7DLF3+JZ2OGOF5z z9~)o%U4-Qape4bur7(scXW=XlA>9Q0toGKawknl`G1F-U(*~0#FggbEQf#j!(iKP?O)B8F^!I)z$F6)k=0GCGI8KXb8P8+)aHbx7_i8+-0CBSIfKwljY} zEl1e-9KMy4t3xa8R|M8_FK7lSH>1S{B^BEMK4jJkJ)Zv5-Z9mSLeU_FrLdisO-b5x zFL1YpN%zxr$K~gINF7-i!A~`bZl+CZx91X(SO^{{39K_TL<7N(Tst<>VnyB)obYpC z)9JSaoHy*^Jdoz*m>uDNTT0Alyykrc94(0lY$C6VNp{VC}iYUXP_tO$vd!;i^_ zIX28dH607Kao!9RNbN|c?hE`O)ev$)q|nR?<57bVXefE`_M4Ec(LC9%i2jC6KoiP? zEWzv=>u3y;7J}rU&-e!8ZO_a<_P^+1u^qYqXggH=9~l6!8Y0fhes)|uQ^g!0pgiE6puX!CBQlWIN2pRsjwjU7k@iw32PWFr|Zl`1@Pa>LK7 zj?ro{^g~6;g<+euqbnot*iGZ|S2KMXpBtZtD`SWxw)+Yi37-*>uY`oES9KzxD5tuj z`K;nbu2>IVlU#BBj)V3D>8ik>jLaPdj%= zY~>S!vDia(Iu=_8dl9jJC_xNrZ0a{$h=kF3EaHyRFkvNWdl`Np@lYfFD9{zNHmPYT z_`&125!Rsr81w8v1;q4L0tgX$2$lVR=H8sI)@(DmJoGQ@+Q5x8G&zgh|0#IXfj3_h zzI@iydZOFxb8{u(XES|Tlz|;|B}~+lF#0I9ts08Y9N2=M8kzTZ)pMjviUb;PFtNF0 z_YgiPQrU~>FmZ<6k%OxQEB3fBPQI7EUiIJ|xEvQB=IZiqEEqu9)S^Kn%=6|(!=HXDa;U5npWRFtFq=c+lvcyCv zOJqs*J!?(YFpRNpC2c~HAxn~7p~VtoiLo!u*v1fI5XN4Oe$Q0b`|EwL_xVOm zHP@ME=DN=FJaf){?&rQg-}@k#F;k%KO0xMmF+)2X@-2^)%^?}+r(SJj8?*F6=68nF z4*QVu3t`gqT$7x{$%8%{)rRPx8JTI>tuVVsvrMOql*M(0_yh2cvZya1r8vxfg{|A5 z{ya-}#evl5L|ALHDZl2NOU8BW`c4#O$>;T|gz~o^#}C!^9MW^;?Rhv7e_wIQsV|p3S)8~DGjuHvPR(xK zdwSUeshMD&YO!(#%nYF`FBSbUV}ESq`n;mNCc9O!)igaVy`UA|;Wk%rYlkA7xQhr9 z-ni8#Rq&zQQSZ%5urqTQ1`&OP;oF>ZmRk;=9=Vw{JZCHCSAU4I5ffAjDlD?!DU_eH zuoBBm!82?5o<9vTY?=DU;gigKSrm^&lc4o9x!R>l{?N6RV4buTj*S&&*jlyY-Tean zL|cg;YQPf*#@;+9@||X!p0}0g%uG;omPVItQ$8(C=bgurWS5s}1Sol~SiaiDg(4%o zRv#cPrzRTB9>ye&$^@hxqDBpBg?}P8Cmq+O(Fayq#mU{-Zv5&f8=P zD_kXG(WUroLe(3QUE@#AlzqZHRNeB_1F^J@r!^bH+|!Pd6#B&KqY=r6nfa9Vh5OM! zLJ(XqW04F~WsD_|d)z&+5h3pS@rk4+&G)lfZ`)h6jSZEa#N+t5Va_M)lpPa6d6*XI)3iwlThDtoD(BQ`~9`f8GTz z)V>u8Z@RXsW0ZW9v_yfL=uQ8TB>t)wT%zzJ9>#brvbUpn2MC#ha9Ke{-nAYf^6IZtMa5&yWqivn-+~o%f;b+Q?1Z?+)6LHTYqxsg_Sx+LeF^+$LKcf}DSZ)Vo>e7zeqWF(^mtw^A1MIAg7 z8q?`^R3LR*8n)o`Y=|s8__5+UlEH2&OdQzqg3ZdLQn$Kk7Y}leXhDdNwwFpUJ!YKE zboFXBU5tSc=bm&O0V#NlB|bdtdMWFSL(Lb`h~9Ej09QQ-DgyndI6y4YkJc%SzxLys zP7C@Tq9gB=DltUtv@Bm#^>kI_j1ct?;W+zSveu;V{e7d(KsJ$lptdC3=ozXZoS!HZ)ngUa)dPF)uh%HI+TSA;; zll0By*`$!^Wh>2*OV%f=-nt15Q~CpCt5DY>=G`FiEngI25|tx_Y>3mBXd8Yv!aG86 zSAXTlRIFeO8*Z+%UTf&l+BOO}x#$>>X<8n;f6=gb78SL7e3i}_t!kgMBK|;a%-fj zR_q9jUpQ+eaatQH+1JIm00H(7L?&&9TyCB|g!gH_*~tk7;p^+=Uv~86N?Tc&tgAnZ zMlsx3=TOhmcy{C^h#o9q{K=pu_Vaql;Y-(hv$e&ld#3ZQ4Dyor)b4GBV`q1>UYUJu zBKnaS#ct+Q6$i*W3a3Zl;uizr+Qgu_HVLi4a-PMS>QhesDggWb&|bwchFe*IBA5jDG67K5xUtitN~hK(hNQimrN(M)(@-H#HV3b6TekV zh({KY~1oUdBrE}thK^XT)x8Sb5F6*^?Yoexw0g;NPpKq%KlYlCLA-=q? zdMSsz*3W)+p#c(%_&wYqN|oOB~UJQgip2a9s_3J7P6_1mKYjXoZ2 zbYpMp`S&)$Q?GcP4-B@$Epw@jp-9Cm@6^l|J=~|xnW;`7}VZRh2XMN)s z{BG#Vt4}UFcfYrR*w1^3w*>Q*QP;kg-A(b=xfM8-fL`r>9J4jI!q?*EE)}n5EeouK z<=hT<)P!{)Vdy+%j7R1H^LSR!>=OZ5I6l0iVt!kBxyc{HTJ4&~rjlsUXdU(nQH2b5 za*1U`lG_+ui0|BL2{{&X`uwEjCa&&ugIbn~vIeG?*N_DpM|gZpKJ13D1eb%*co&xd zs7Y;V7aDZ`!aB$o7ocBN5KfC<9UUycJZ^#>=+jm|JQ2-QIxA2O9;DOM=@zi%qv+Yr zIC&p3o13d1m*pEp4(l`HJgY$#w$H?yhn>R~l2k&4Af5r_LeMQcQ&| z2I6sIv&Hj0CSllk`og`w%I=imMbE)n)~75IieabE6Kd9-q@Ry;EDmGScvi;S7edEe z+M3P;7iv(>=g!H^`57*})J%Vnq83WA)V4)@H#!Mf;6(;Dt+1)ia?B~Lx-DVSbljo& z`WdTJAi;5joSx|Km*37kPnS{MwS6+1KZz~Uix6%{?#E%LGV6Ebdue?viY?tJiE(*e zY`rVZ;-Lzt(qNud{{@q}&{OA=gnRvD z=T^UhX@D_b#OIDUk5oZ(4KuYta$50nD4uiG>jaJ#Gtfh~S6V-P<2KP+tkAraTZ#9? zg!f>-b>Rn(V1&gwsZ#&?Q*3FS)ss(A@>f6T0SG$N!wM-9YqS0tRh^aUzC>8#k>k+W zCuZ6QW5C(s2EU5tXYlyNcX*eu9V!9k95;3>=7DDU$;N0b%G)0n8Zt$B>()Ioz+l8M zU$lidZX?vXXY19mX!^zk>DxVMQg;6YU4}vW=P=zk!h^xWFpiMwRtTg1niVVWfV9S_ zqr1RN>)M>0dUseOgFAI(Zjb-F((3l?TY1}@^y3B;> zPEkjQp4QGA{~X@PDziO2(#MT*S2yCJC8xzTHKZa=9jXN&cEjzJ?#Hl~UK| z>&;d@6rO(*(P_RZa&}GD#a6@nqI!?xE1mQi(txf&;J|#HVp%metKJD%xb0L3jnLSEZf8h zx`O+a2K&0^wTU6m{-^eX9G~b`>&P_EX+SWsCHubsHBm1nEu zvsZgb8(}^_T=`a&tTSFyjM#Mtq)TFZ{}b42x26po^{K5Px4(}Tk61dCC_stj7Bf|v zP}P4$)eZ2O)BY!h7i72#(?c9$gUPLE;7S6;y077)TLd*#_bUT0x$XGtshJ$W zCMNwGE9e{duEe(9qtpXja3*%$b(|sB_d!(whFv3o-tFbiqAUV%RKI|>4&ZG7YWEL- z_lHOPgJ=GiG5ABP{@=p89Fp)Z0i|)b8vp_p;(*YOq;{lS!UP9hy7rR;$BoSBk%0Bj zMJrQf4`sjEPAz7>%w`!Xl}!bqr)no|l_^h5{e#HnP~bEBCT4IdC8a%oA8TjO+kkAa zl#m4Vm3|=9rvYf3v>7W85b&!2{ochCoeUr=f)yGV)-d1)Q|V1U0djDw013VngYXgSxtIt1n7=^&_)j_e;pTf$& zzM^U&@ba&I3fBJWIfo9Q+#=-psA|c-8o>@#dr-L`s|1;r#&hX!_!9OPD|J|f4 ZhuN##i@t0#huQ^Rx@RwFm1sCb{ttr>Nmu{? literal 0 HcmV?d00001 diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-many.png b/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-many.png new file mode 100644 index 0000000000000000000000000000000000000000..24c7422150ca0ce53a92b4e149953f9527a696ac GIT binary patch literal 56791 zcmeEuWn5KX^Dm{+DIg#ziqhTPAtl|?-OV8-1p(>qk`_30sg#IxcXzk+UFY}Y|GD?R zxbN2{62SG1tOyG(1eab1~h3<{aw z^K8yFn!;t*pB9WfSH<(!mC2-kHI>D|_$EbS2MOuV3q%a&bf(X8Jj%+;aESr;hwJn5 z{;zMWF=fmBq8=u5Uq0=91V^EaU4*j!K{pHw!Ji8yznUK|?A^~a#r`CP?(N?!yxt&G7WF!L~mr+OO&;?9sE^qUOTQ#p!@z$qFo_iF5w zM(kN(l7?4$#e)hrA~dV^sg{AM-oD)w>RlrmKjDoU*+Yfy9^UpmFgEgp2XK@bstv{m zcfK|b@r1+*@VxyvPI*ru6A>1qNdotj5bb6!AvvWO`J={k9w!11;RZT0CE1I?kL%~B zETX=`&pJb}i{zZBe%#oij&_O2k|Ya3-=_CO1ZrZf(8lmH*DY=Y**N|F*2Fbxs;eOM zkj}JwDpLF)A458*ylp&_LZ#m>xS{!(C<&Pbf5qS1Q530@7+dORjd91d?K@P~MjS*= zR*6rn1ZZKVR5D-NWy$H}lOA=Q%^viT%R<#z+NTE$ly1mCO&W`Z&u z>g7(Xt+2J#A5V;M!VwVEs#Z;6s0G!#R=q~}AD)ft&MBngjoU}xL^yp&Xd&u!Jh`0; zXuR@ta4tDJrn1ymP5-6X!XH6STyXMa5$y*&Arc&c(zn#duL9{Q?Dk6zKRl?u5b?({ zp+F4t7ZYM*eomWF^V**RvJUtSE-g!Kqc@#e+=^@@AVnH*PLd_VDaqL8I5qd3hcO`Sf zBGiCC=F)ZNX#WK>!SR(H+;a!Q2Fxdy!F0`{iL;0GtaN3RGky_Ep>$*RQjWRV@rF-@ z!uy9(h~0=Q0%?EgJ|vSTR9XgJZD2;B>t)@DT?Bu{BC-0&@B{Px=GVBXM`b9bPw4l! zC;V&QOC@+X^ayOvIwF>Kon6IYxP~009BQbi$TX@%suYb9j%#LGXlI_7ttlibBzn)(^}&ZO*5DPrluF*xMh*9O`a4!d*0W zNc~toOvfN;(;kE4_S4M%yk5$!JB_^*7l?T%=<^=^cL2tx9-;<#rlltcBCo@c94S!V z1}&KX@L@z$`Tmv}>uCV)*XL#M>+kWVP)okqeI*|YeC2?f{wV1iz60W1aB~r23*7t? z>7P#&$S66K=;GuoBJ?g3=VYQJoYr(zLMw(%6q!@lvAWDd=LHOwis%?ulW( zpou|~CUJV=8RkM&8Kd|*q$H2`3yxKAl=$@PCm3R@Lns!Hc^@$d6K2Z|vqEv_gC}xm zhUK;}prk$l7w_RzI+By$Z1`8PFcN%xX}q0`o)(F3T%h?65Jag|!bLC20|y)d#jY-ABR5Fn4Ld#9>lQx|mntR|&at3@kknylt#L)E#?v+2s})a=v_4KJdKfQ`Y5-nS-S zvzmd8VNyG@#@pW1Ho>BHsAOTMu{M)qu<2V<_jM*lfA^$WxO_pst4=P%Ea$9S;66Uz z*uXgBXvL3%Y5}W5Gp4%5_luk#n12`tnTW54>0s_)J`eQ{B@PXZdfVgBBiiHDlk|c? z@>R4h%gNkNdxZ)G9);QTgQyWQeBKfV)zvSP18xHdo2Z++d6s#Bl7mF^%-0DKtr0&> z^ZINxLUuJLgDd(g4Cd$NP3&szG7bn1iVt?@?WBcu@0k-Umaw@v%!5oNP2RO#d<1;AxuA5MRAqM zQK&p)N~sQ|w4~%e^5V*WiVCj?^Cf1ZH8cC&s42OZKb28+xN^Ii*fk$n|52lpi)IM# z9c~HU0?lLUinli8TuyUkj!CiTv7Is)z4>cFYx}(dtB5_7M2j&isqf;QoIjigS>iFi zWlv6KJKmzcroPWdEDjp$9Lp#+_!Dz0>#Io2Wb30{?@y;m=RtXfS3%$w-XcLKF*wpX zs!=F3cr>7yFr2cTrjm@X#y4EBq_+!dGtvnB}cg zO|<)5nztj0gy@iI|Cl}&6l2cwEZqL>$woe~@_UArBqf8h*)!@Y{gE^8#^b^ARH=+4 zfx}7Bq8|yt_M0>V_PXz(?+-ro2t9gGRmoSTrfaGlvR*Ef_(OG^JJFR#SgCVcuBqu= z)mfYmUF*aB9vdMPeciaXxlphzr<2skFx!ycpj&;rfx3vVMpvs&Q0CoYwO7(KUN+uU zx>%6{-4HF7+b*mfs|~N+H(fTx*rlIfFt{w>uep^B_Up<#57{NG^lKMsSl*FrkE~7m z#F=1o4b^J7x=37Na7|E8DA4(+b6PJrpZIluj%m-Jq@KivY5LwZ)IT+i% zBZMz$JewmtbwKY;bHyI!)V$KJQnM+RvtqCQSB2A>oZaKobPRhrd2k%H>0vH1s7eupZ-{m*o!z%nqwTVZK!*G zJ$^nZR&H1Bnl#VErF+~kGR0ZAKc%)lkrmk*NRem4>sMqSq`>&Q z%FO79`K!T_EIuLo9rT@+lb%09y!z4x-UqbTD!YT{Ot$)Dw|$3|i3g7Jq?IO>UIt8_ zc@JhiI!X2Y0?Gnd3+rt+w{3p{??X$X_o6oiU;27ocy2DwGJsiusXygVE&xJHIUf+vQ11g_x0M-ZOmpVyM`ui+5?x{m+{ z7itNI^!IlZz&GsYGx)&9{OubtHU#bo_zf3)JhBn~@iwA%HsU|75&nQ@a3U&VGBV&> z#n{Qz)Xv$$-bK1n9S__hJzz`3H!jyyrVt>^d~G;HC;62-|`yU+cFxO*c+KL zde}O^=)m!N@PbQQQx`)D4_g~MXI>8hs=wag1=q0KOjHzqz2agmK&2_KL?LGHWJf6!=1eR+JUmRytW2z| z4B!n0XHPp9Lk|W!=NEq?@(()Vrq0GrmJTkK_I4C7x`syft}X&pRIrKu`SZ7RntE9N zYbHDAzq17xWP&|mVqs)v`X_BLlpl7NSIN@D)J99((iX@J%pu6d$;|)P`~TaMf6e%B zBQ^gulADA3-$(x2lm8s4=4|RDW^W6obP@d5cKv>&zlr&;yFku@ zsQgU-+%rMcYq?5wu#L|x#T8Y-H&8Oz54<<{=k?#dVb>qf>1N}z;oyYfWW+^OJ>d5; z9<@AGyXhep!KN2g!cqGE?cm^tuutSj<~Y&S0$w?oi}K+8aR93?1a1E zl(CTlZiEJpg*x^Y`ctO)suoYL=9+8RXfjWI+y?jT6&TcwKVVb9!6OU9Az=H%A^mkp z!Hy6?2^BS#k^JYazdfag4EDF>kGX(Iv;IRK{}t(fMfzVY{ilJzFyQ~$@M#sY?HAiv zjXJ}^lf>X*cD(X7+xPC8)$m6UcUqV&z?d)?#{8hBLl&ms0?1&;m>vv=R1yIvTH16g zZ%g@i6IM+JH&(*lfaOTTf!7eygFa#-#{eV~Pd0f3kGux(=U0Dl4It=>BW$6T)LzU0 z2*ZFPARO-}fi_=yYnICv)UvCu01n2AGOlLB`<%J9o`MP#WCp@VPq4qB0%Rz0U`q%R zhJvBPCq*t`O~l})MS}`0(uyd67(&JOPmtaLut_l5e8&MEGl8|}v+Mc`t6_s#gVTzs zk#b-94}~WR6^Owh41=2z9U1Q^u?Yd01`h;2iqA&i>M37R2|!9ufNv+c%Lqs%9{_i> zb8lZ#Vlx1O(Azhp2-smEfIH%Yc+`LfK&Gn;Zwfx(DM0yxsYX={1ni%3U=@TIgcM5n zV6|$?X0#{)7Jy*xy5csF0y~Tahay$v&R{@>G4mTHa(*(n>KZ7p0+023$mp_0MTbc!2g=ff(gS~74 ztMD9lfJaahfy+_Dw=0ar4od_CweRCG;gQ9~01FuIvcbZ-fXvDY%AUV)IFQM^J>8PW zKuN&P{E7%c`|no&cdP&Zvg$8xIUHAc_9ip=S*8bNJSX*6X*ZLLtbPkZ)taqcCMoNw zKdbw=;s{DBUelGg%I*k0sxki^tElg-p{{MNre#^4@S=P%LQL`(UJ_Km)u9#-p_tm=b`G}%B&44-w8+*@E+SJMuU z5XOapVPWVOMq!5~3)iBRQ%YSVTxNOaeIZoWY^}BN++wVTTn3sPheQxYl3ya9Zv0L_ zn-q||zxFBQIA4P>#ztOD^Rm`twcaaxu2l2cUiFo?d^o#Viu!4TqJ31LMX|XeHOeML%P2rr&lxf9&8fily4$@}x;qLV}I(lqvDvdqwkhfkBbh{y5 z=exc5c!#ltbevdS3@WdD=_(gb%X}QZ?!2-Ni8aS%6R~(XY(0e@a0I6LVJxEn;E@cV z+1s4jTPr%5yn>|oX}{mGTJ3j+p?GF_vmT4K`MdM2)rvGbtrsSGUoVDF2Io8{4qj9W z>DBR>4gTB#C?o#xr@;N!lt}bUT)%cbRKT0I;X_brFvo~r5}WDBlLyp>3itE)$cGx+ z&-8d11UU&7mxlaE2msz6NlHHkD_{XDkPt21zuqsHEaftxxH*_-S{vufN_4!8>gQfO zEfA?bkt{V&mzzoJCHG}mCHGREv7Hg&1P2zo4EjRVv=mU*;oYGD6s)$_qpu#QX31)s zp*EAWY#*ZCY3UVgt18oQb840B7FXXTN!?waoBjwwIe=gSh;@PanA!F*OoS@n8))gF z&uIia7O*`%w=6cxpN~9HPp_}t&0Ri6dI3WI?#lQvAS&523)7P&QlyeduzNH73Oq)Xbav zd8SAKl>*b7gfam+ruwq9iYXC!`N+3-M%CVz1BCC|MSQf{e5`A7SAU(1*c$)RMmPR0 zgtpc**hZLPKlbhGNWz^uyI-*S<|sRxUz(wOnYkBJ%jur)wRi0Lq(#b0)5f~KzF#C2 ziNhU5lYVy>G*_!d+tquh{o_%?;@S9b(s>a zCVv~4CmZHINDrPz*Lv=>cU1DIaqdAAZrXeUyG&CEInm~s{9w`(9mi!|kF8ru$=gqLMyugihfq#*o~Ep~ z_e<8wRcjc$Q2D-JFZa?tXr0KK*W8F6me6whenC3kNoww3dE>t)Mr8=eNhjldQ1^6S zd-(L__fu-xr+j-~bDsO2zu`$z_1paT&^xqR2{pBt(Ka!HyiCS4OakTJ98zi*nPa?yu9ED|&^y^mSmWgaL{AFo2*K%Mu; zX`i=dUxa6q#EVUhT=#DMZb7A3P|~7crd1UaK>`RjoQeto2sa*RHG9taI0<qu|JFD}}wb_0Z7vp=_ z-9^dbPO@%g9Je7F#$OfmUCz*+Y@tzw+xH&H_2g+XNI?C*_gP@`H=n9g++AIq{o;K~ zV)E%mPAN+!*M;`sn@dfIiXC5FyEl(rE~H{eL@?*P7wzzu-h2h#{uSL*D3{XR=rI(L zEw}Zqvy^r3PwAZVOkSQ~A0}Vs*ZTeaVf}h*P|?(5rsm5$z4ul*bSu19K&FPO3v|+V zo*a_tD){amRt392zSf^kTs%&+o+Prpx4sN>X6tp#7KoelJ{OLn+vX1*k}WbQ#z&F` zkrZvh(_@f^F9MkE3QoLOnseDQWj$`J*BvnDU(lFX@HD=>^D&>Ncp$m|bfX_Uq|Ik* z0^v2gWtSIxSPaem(19{@>a-E&doaXSIpnmmRf_ph$2jeLO^40(Xwo`=bxGCFQv4ewY;0s>c|#-HQl9&Jl+_6n?&h zx=byszarP8{((YkH&Zq{fG(n|TbRtd`?y_Uh^(|m@;r&7UT2Z#<+P|ASHVP1>aT_l zH`Hy;3qF0iTE<6Gh4$4$e|nU(Nx!SmfS?OG#9eMj%@BTA*u zK>H}j7)9X_3_&8WPN;poY5+;lZ!pc@YEx0rw2i1O=&70Hwl5EB@@>yN_{_TxKeB*( z=uJp^;-z1Q*Y6e^K(#h?4>&KN_lO`iLtMH7(#q zmqNQR#MOQFt%9G)@Pe+_!L~JdY>dz0gcylxeuhaR{^fwiXlCB`CwhCR5jHsmq8e8v zn&a6WRMEuVAzQ3B3r7JszB8-q$WQSllRh<1XVfP!K-u@zL-%Q=Fn12@M9Y2O#gw`7 zTCWG1R;0Wr64^A?JGquWXeqxkYa5*+A*<;)#%wv`Ch55%6u02%7{1groKO|2W2j>8Cxq( zKdVk{8KDrn+^ZfkG|dF#p0!?7pLtmM-vsIo@`!9cJ)f^gdwb^;E>O^_Z64Y3gUlSQ z9MOxM29oaiId05HZ8&#DB9O6XVY7TTKUmAAgGALr_v^s0-C^_q+U({BiPJuIJ04NF zU_}6#TJS3jQqiH$#|O`1BWsFW1HuIDv0U@ET8b9Z+#I`PXED^{PP0h1!il+Zc|5$b zaZHjP=zdlMbNeypGjnctrYm4RAd!K{c^e-vpu;oy$LPF#vi7hhIFXUqsVcna`Hf2P zXI`HIM4RZOSB&Ojo3g0o6mFEzx#(a4B{JZ+U$q1ZbHEbjxcXu`Bq}Npx;)hmo*QbB zm*crwh|r5EaP+&ii9MUXe#$jHZCnx0QLo3Y+uwTh@PrAA%~V5PV0G_US-46 z`{$#X5*f|~lsXm}9yeF&!qX8v_j=V@gly2`MapP7`f2GV^mgHi-3HI7J5}fsk z`R2M`alUWT_s%~<2)}NqX?bbkLJ5B!ddp4_s|~Av=O(Zo^>&yh%kb zahH!S7)?L2D$a@w0n8-IE&S1VKZB?B03fQ;LAk1VfHBRaV8EMiIVWlE5q(SFk@=16 zW(EEXvSz5k0%Ri>qs*1N2y=a|^+v=EQ^Tw{V!?BNe(xJ;|K>)E1fd-NFD@6QxSLwE zoCFl|oP^#$ckC~mfD5Zr?(<+_B%t3BL5bl3H9fkfgc!*j`-+YC{`wc1^SNl;y4M@! zBhYatB7NfVLo&~u)F*5M38xz~_GD18lOF`yT%>L(e3fOfqPwe`i!mga`p~mKu8Tr@ z2Ula}X)5bIitY8>E(pV5IgHzgnBg6lC4$6j94h!q4U8Z={Tr(}x$pW?;|s+@uwm2)pw zWvuQ#F{ubf=Yycgi-j3<(w3i|)&`*&%)$%4xA>RoHBDKN+;0JlI}TTT4+EC6B|{@G zCtWGgXt+h?2)}Z-j>G4Q$^EOiuM5#eeLGTu8eC7OI89&dG z@9=_x{g#!w^4uSx*`}f$Sqsa6^{L)-gu>s21tLnM-h`YPUYeg_ppjo4Lj+)p(djuHj|Mj23k*> z2&Z@y8B_Zb`f0%CdJtB)mo;lSv3yU=sJ!&S&RJi}liL+?xyK%jxGHfjnYgWKda50T zQ!oVpyJ^lbz{cy_g#v-vW%y7DZ<2~3vI+23rb|m0NEAvaKtF$o%B4UcGXD|PkR3UbrCC~)&~ZDv*d&A z4}a2J128f852vjqxE9Cn>bz_{T7KWUhO`#;`CpRxMX=j9d+WPzW3}u_JR5lp&1~OB z@jS~lWDFH;RSpiCaOUD?jMj5%PKypl#7DXMa9~>q`D7aE6r4D~1d{jX&9o+N`n>Ln zn@2o351q>+Wv+5Jbt9sEm3mg`?%bQ0J+83>h|N4|Uz&iUA8$-Y6{83QN`&~t9^_!M zKy>(o+Q07ZXcj+%4BS<3$ZOthq|auhRA|dJnr42t7p2MMPt0T%V-sZ+*W2qQYrefQ ztz81M-ow+@Bf z2l~%+?gA?8nUD8HW`@HUGAh^6m&Zb8iwYleuo}8$IF}PkIJN_aXb`B!A|`cpy+0CTQSwro}4$wFbzhhB4wZ zlq@{m+ubY|csGw`>xJsOEPNQf9X~g#wlcoYJsnK69PVU!F0BB`# zaZoV+0BVB?ub}a#{TVkuLlqSrVs=j!V7}Hz=&a%XWFTyE&e|)xudANb4{d`fzBo%U zHmm(!#3cX&gV#W2lYxg42xFIXIfrn4AgJRs0?=Yke54H0^3=0Bq97i5G|RJ(g;(3IWYJ{g z%(I9bXGT}+kf-(z7DQo;EqteQ+hG*)KFP4IycCu=I#(g9Z4FsCwd4os(s1b*3PJ%W z8zc;bCu95jD#bY?-X{U;h z*!1x@I!pc+HbDf_urR1FMV2-75?AlOxtS!MC=}h_a|>+}+8ZQuE+}i)}AJm3+Fm-_dqc1Hdsj1qyB3o2!)7s}~?2mR|*{&$jj7p*N_d0Hq zO>Lv)R^`gR@}Aq-z5+2p`+HZ_3aULGe9f%3v&KuV9_E0EX^g;G0z9&6^AM_k#rt>octR=5A9$mSOq&ya87~pirtR z-1UT1bERfjiEqA5xX*gQ%(Et03e7!JtC*9ThfI{NccH@#8Honeg9bi72k=D&f)EKf zMqaQ`*6 z|Gz-yGXwN?LyQ-8)GYH#somEnh1pOvw$y(gfF2QmN7HXqplrJo4JX=1&h-ue_%_JB zWs`)8F_6MRK92vQ(-j;qGXuR#v_G%RP5IvOe{s$6bHxTx8TjIRY_X# zrXHZVeL_R zR|aGz()EVy;0#z5p@$4o_BNe+IO(c>!K>7!X}hp(Bo-M#1m(G7C(g-wl0o8p>G&7r z7mYUu%>_O;7b5Z$0-$9lKpe3A@|WH-G=TmY-^Pkn)<273#!T0kC#fa0Ud+fbw%?vq zk6;ra7~*wol(9)5{P{ITn00fGmpO5($Km14E5S`{- zClkimj()y~mTq?2$5m7OBu^f|Hoz^W?{{||OIPSzeb90`5W9+p18a^-1@)tmHTfOj zdZ|PFYnUb9Jl_3WUNgX38vms;Kv)9B`_FQy)9rCjl4^IX9OD}-q1&%z4J+X zoIn951^szrPk?Mef7oIB;)T`rPTz=t#-z zwx`*+k!ro?D}Ehs;OD>NokVEhU6uH;=|QPfz1(E&XH0E{BLadlUc+%WiRquCWuAVE zk^GP7&$*RC(21z*Th9|CJB}vPzi^`faf1d9x^>J#FCe)yq>cVG^G6e_(L#2S!$mi= z)n}FubB)fnhu+3(u$^E z5Lv-LONckw%cF`ywVSTR?un2=;Z71@UIoDD-ni0_e*i5LG|U!56hNSmGRo_x29T<= zV&95vh>2$?O3BX|hYk#9iS_45Mq*3Zftb};I0THkuNidepjpcy7$h4|`=h1rZmUME zUlyPEGz{g4g{C|50HFy}V1OAckEmRM{TN9s-CZu$w`vaH-B#!|&&caU5P5!sfAVv! zH*Ozmr~^x=Mbo81jKT?F(fc$q-Qfp{8=~DjiCvm)-5ZP12FF#K!^F{!(piG7TLy5b z0T;jy3$Ff(-Lb|o@Hu3!DmTIqo8mJP z!fH=+(xBkYZ(lpPs>>|D`zoW|svfeM;rdxSz8e9{(L(t*y&n-_OJ@N~|LBMx0#l|} zN5G2he>4fT9L-nGlT^3au0H;&tb|9yk{&%L3fOIK&T_R5@`>-77!bAqi~B8o$VZ4WQ#nJF<|dJ2nkll<-I%c$hCT6RnPX}@CftbJZn zNA9Dli0^EcZAMz0aZvL z9m!XmMF9q~j%?z0IKX$vZ@Jj!vyMt_4)ax%KqrEc#wsanP1b!MR>3v77r(y_Q8vE+TR2?k7Oo&g`j?3)kZ z?47i*2JV#06BJlr+b;`p8QKhd7LbC(<2_?F2}390{5=7Xram5xu|asI&Nl5t zTSWkwIu34e(&QSWlu}6^sQdVu?G7sFGon5M)+qgOe`k98=lH@4c%FRg$qJrTCtT3h zg#{0)qL6IA+heKWD{Vr34@#2Io*n^Bgn$w-Ysqyq92|wxJW_WQskKDnK%dokNy2{D z4Dh$5B&fBa!XJ>g+%?GKx7@WlmP5%I0fZ@hu<|bk3~Pg2YGg-i2eiXIefbYGvT2DT z<8&=Xgm|*6HI7`ZNqZNNR7FoVP*55E0hR|e`i7yA?}rYEa=ti zX_3SHjF)UYPA?*kp6 zcP`r-fwx6kV#2`h?j>DiOZ6RP-F9JGViwLDpk11{Lj{YK4J)WbY-XQo55IO`#sM_o zN~|+_3iQVg7>~Z90KQDC-p9VlR@?jLkX&ui=SYJVoT!$gpzqlSF~MQ z>Er>x(qwGfqS7)=PFSs0pw-&FWP#pD3MGnmR4saoTtilb{O$MPUH@kxV zi(x3K+gGvBR4#k{UaDRapbl>WfNA!D^;?wBS{7xjflwjFG)a%MaKeZHNa8V|!R`^m z3v9C%S=Vjalogl`=G{nI{|^W{rc zQ(p~re|KPlSwfIhEsB>$vQN2Pe;%{;(c_89v7i8@qXO7~+ZxgjCV31$pMG}SLtekx z?aoj zZ;qY@<|w&r1Vdf&T>x}aAJWzTb`Jy#Mhe24w%gtV?u$MeP#WD$hyA))JC=IlQ?T6) zpa{`5FE$0xZjCxSLQmKGxQo^E#H86P`JP!D1c}bw>(nspeur$q2g7=j^G|F8pYx)4 zOX5vB0db1YaX=|)2o!(_fl~y)I4e-KF_>k~MLt0;1>i+DUA~(W2!Rp_#09U!LeQ%w z$h@|4M)IVhgEXU302gy0>|EKvfh`pkx8dRb#&qdh0B00qZbqR>jaLTOObpUXTs}nei#_SLWZ#_RWt<47uly(nZDPSIt&5}_U(5g z>JmG^?RS3iAAsK7}U#Dk29%!43?rPbmq>u;&SY|BB4-jKE#sCi6HK>N^3QHB67Y2$KMWj9}7~#r=4L1hj!2XhZIN z=rOi57raku8P)FTpin&>+Ro@&?SwYnd_p1kUjxGQC};b#8j_!$8QP9E=I~0z1MvuF zV}qfTyt;G|{&pAA{=%9_E)pX9{n}sO2e4ZV2`6fq5fy7x>T6CHy&^Xq1;NSKSZKJo z;?khdLwuw2rlh?zIzzGmq;4zg@xGz`T!SNed1rn;uBKD(%l%E-;tOMUTVS&@Z@E{& zUiZ;($Bt7Sfw5utQ>DelRcWD&&3o0gD-J8LGL|5UAy6s&SG)j4uE2iSP~RDr`5_?( zf$moP$6X%l0Yl+QLb$dVqIAOt5s}BI8~rs@au`-)Ut;K6&qk`M7@|UeI&3AF%mxk7 z9tdUBb68K%lPE)Kq3#iI0og#~MNqn#1Vrh41!c-4kjp!p7&$|B9bS?$ehDCFDrkK4 zSN$+S0jLEr9T{pu_eagY6>@3cu;6IR9IGPiiCet}jq1;x6P<|OcEbxjxrsoUt`>2zRgeb)>8 zt1`y=7y0;W%qlh7H!JJlsO*>2tHw!m<|&%* zvo&4mZ}#=p!2O$4FfnSdm&x{tAW3S}rJfa_5ufTn{?MwoZo zYk#;`PF4A8bBKE|?OeU!_u%7>Ri2=(Eu(JWbN<0|y_ws%8{HG+v-C+Y^)`TpXHJrr zYLnC0RCN)!88C0QwzXYtq+PJ(zdch-ikKj`Y;xBO#UwQWc@h2fw5WyWN2lkH1FZ10 z6D*ioj7rn*;Ov|CIU9k2mXiRaq^Sg6^ciC$b@bPnQ|>wI-4X#Q3yBlua*G_~^6-`N zir=L&7RAYqb|JeJ%@udbTS0t za*!Q=M9j~?k&Htc^H+JCH-1Wz*ir*tn|MsPFm@X^_R0~_&U6w zz7`f1uJup0UJ}?&E_!Ze_%D27Y^?V=oy~-JihEX;mtH*2!Jpr(efRPOlvbh+^|bs| z&Ulrv)YVcTR$0>SWF-@*fn>t6^B7M76j8Y(;R$k5;uBlR=iQN>ds9_TYcWzyZ9@wJ zKC;gxVaEUIInO%~0HXm<>;9d-qk#p2X0g!HsO!0&Q0qm9qpdIdJXGZG3p|&2v!6L> zu5TDHgj>AUdI8ejL{QaGgryAA9;=b9c3)g|HTHQ#O9`$!vp{)cA^5mhm;A!82UILHjsNzjgVprcQ$GH;q3#kyEA-H_!a@P9CSr}MkIRP0 z4CftEW!|xSPYRlVl?Gc)qR|X&qjU{ zBW=EZf6N0RX2*&wT`fA7gmx2KPmOQ7tQUHV*y~@f3PawyFh<}36DQFr#MBu52%S02 zM??1r#M~XaQqS_aNZDc**4vif@53*YPusYka;?}^!fv(n>Hv<%=2Cf>yfbQk=G%@a zmUiMj_k?~7EZZc5j1urp@b0W_!RjI;u4-*+>RJ-N@U?g7NI8!|1gH5bHM-(NRv%JfqliPRgGf#>;%oYh0&+@h!8vh2o@^b$Gqw4bAvg(0S+#XFfQA!%u z0wuebJOm0g2i-05;g=#P+@gJh?t;xWLbj2kzB?;S1Mae$Qag8I?Ui1NLka3Nvw93V zv+R*Y`%QK0*G2n&ZVqje)0;K3XqyYUi!}rc+6}`AF%>13AexxHcn<=a-iFHtoEl7a zA$(op_{asUGyUl{AIK~tu&@0CQMs84LLKyu!8_KUKH7)x7U&DdYIkIx^lHAqaVMTF@u>8;_w{xW#~}ad+ejR_+LPhk%`CsR{to0w;?ahQ zWXW1}egPrJl}>F_BFE2c6~>_|SfSrRfyVlD3)Dy>uauSO%AGtxQPMhLwO=v1R&ckx zUOy7&W9CFQy~|ZN`3n20AJ+>K!NgUuibL% z-_b~yEgQxgEYoM81|icAJf(qgkXg5DWS*tlg6Af2jkJWM*2a@~l1b#~PuA0Ks~0bK zCkN$sk0vL?4VWSqUWLeh`ml;mh&cG!Qv>n-)+@K zvT;=L_<+T8TfF{}#fb@+*Xrhf?L5lsx#hmGk!D|UK2KNM>Ttj?!}(0vWLBWY>q{#Q zk12PhS;8>!ix#Vd^>w45_mK;PPV-p55zB~~oL@jsHt^Scw>JgvI`I_8k{`XWIv#JL zyHrsWsO?4`M2GZQF1CC+o0^^A@jh=$C#}?N{yjm&v%3_ZQz>aOPFqwl<9xa_az%WS zii>a^lmb%>3&bx%>8%)vDF(5}3yp4bjyVaI1$g*}0 zPl5ZpeDkzf+WIZde!qE9hFb}XfPo$HA;+esJ=sRDo}`w1DtmE4_uG>GFyqX!M#rjr(dl8W~?%pX6)`GCD?1 zzCezoppD;4`#u5uH4_^ZC_lc=f>VnrX#Y^9=5_NhXhrDZ4=$f}>>+jTJ}o?+DahhF zto80OvoMs@`_aZiM{~!r%3uJR?NAG@sPM4hr7S$#qr2Kp2QIlSbXIMJ`jLZ)YaVpF z%@=O2CXMlF%$PbpII=I`Yv{%t-_aMe4)J;(GMKfQE3j~EYB@$D z-Ne%tIZRq#C^{F(y3YMv?zO*Nlr`|B`SMi74x<07bs}2t`g6Uez*)UW-QS#(Cvk54 zb5@rj?^Wbw>>)#J-t--C4B*5zX6VIBFBe+4&5S^bY2o{PruVK8r|#J0*@vs+UCg4P zxHP+_;^d+PH|gMQE!_F9LnyrB-_H=HZ_}MY(I#i>Gn)O7VNmmaT^eacAzkM-cD=4h z=%@Xr-HKeX-tJFCs}<@+j-XU()422jZO)|0E2Bm_pbbD@1T5*|io&cP?l-Fm#H?XP zP;~jSY+c#9aX?sb?Be&5H1#v-+f_#M3|IAT%NGuNU!RmO0%y($VnMZTSJKt2*60aP zX|~?H;>K~^TEyhrdmoCngX(S-&94;@e66uMt*YooE>qh2QVI%Q+l0hBGsvH-)+y|i{R#y(e^BUiQ z^laUXyHb76b^PY|N@sZ@f*Lu(TxLc2eFth%t1luxUoJcYajM6>IyBV~|q}_#Fx}Y5a*@st(ne-b^+UG5FSFML`0u~5i zjonsh)%Oa=yE*f_KPBg*8Dr5sOz|3=SWY*K z|43R>(QAX~kc^>pL#;lgF>-XvBHfzI$rt zi)De@0pBG`9427{K+pMI1%A?y6;j#v#Mi{&?=`G?X7O63%+Td2N@kq#>aK#)kz?O5 z_jS@dnjw-_q`O5xO2q2M@>)UV6H=GYX&O0X3=CqS0;$31d(2EUSnE~bw4uqEsHL1QXUdfy+3Z~E2(O_7B`<)Aci;oxM<*7RXtS0tM6UbmM2YATdL_W+loh) z!j7v?tW8ici4+m2Tudf5P?w?O!y_LpMTW(u!2`llv5)gu{uVm4~y1vMJFDCiH;70|wFJ2ks6xY0w- zD?h%luuPe8!yx0IVi0RL-++%JT_~8$5VFns9f-}9Sr@s{(*)65T_+aR{L(s9U46)) z9hHK(k(lQw8BL~V+?k}4=;@H;sJHs;`-Z-T&hO`xY0(!>+gS^PXJkvQgQk}j!LIG} zn}X%fU6ZRjw4_+K92Cmq?zdZi^M5Mi*@uqKIS+6g3UnZsYfol)1nZy9TL(@RSY}*r zm8L*+#_F1^2DgW0b*!yk6&6*>j=iQun9(%jS}HWBIZP*yv?qA>)8goN+_x5t%;|dC z7YARI^X3{B(sH!ID8)>8A{$b~Ggt;5K+V^El+W?37IJxD$HU9G2vKodS+~AD_qxD( zR1+pL;H2-h{j&bK?Vh~-TgjFgrtCUi6AYANH<6$&7mYcv~-HJfHV@)AR%3cPEqMNbc0B@(joAh^E^J!H}1N3 z-9PVL>+^^2h3A}k&&=L?_UxH?z4i<$e-ri}(4!qtbVy#H{5Tlq+>6lJ=NsVC&xKKu zKrcSh>yM=r^pSgd9v;(p_x0HoiuX~GdoX=o7#A*7>~(($XBls%@8s2)B#Ux`iSVRa!dFbyLAZIqKBo8a3~1qjeXn)I$6=O_U8Eqje#GpzUZhPIS(k zxD0T&ub4&={b+B5G2LYXUq9cgk=O};hv*##(f7z?83TM|eYD#;H_{dyPKCU`Yx};X zg~c*i;d6aTn~J)ty`Ac0Nif#VU68Sk~P!#dSl8l}Gv z_^wk#FW&}`2k5Z1m_@r&HIk2f?;KsA-_M~7-pcj#>>Bb07z6Y z{b3H;Q@x?vEmm8V*uoCbb<99fttVeF=Y1ZF?Y10>o}c6&0(pK4La0C59xJD68tZ%t zDFr76Prmq){DPErr!vPCp`I$^qadZYxMXtPOiPn zHyy!JPEBYh{V;F0vX077xVuuzxOyLJ=1I}i$a7p)_VoH`Nw$(pi-uDxlBg(U)_eP4 zO&oFIxxFQV_fdq)rTo}N#~?_%3@lm`S}hXvVPSH%t36Tr8%z7Dy~~|j&Qfj- z9_uiR=x>t^`m*g7%Q!i&Vaf->Y}ZDU zN`2HJf8VlW=Y8};HF1{=G-o&!Lvx4(=y>VQzK@##komMT{GcXt;k;OIvPbB&$0n7$ zfsU*BXMjjTF<$Ttjs}Ft-DFE&llN>F-D#h#@oHn2-mUAKMUm4vMsTVU<*l%8LhUwXVj6-o<5{;C zTym?t#dr2l@)X+UCesj|NcOz!#9%-A!t+6HsQ3k(kuN;lPqe4kS3O@fs#&!jpvjN- zsVD7Qwf8it)uUWV*qPh70Wel2y3XhIkInbX&7@wv+FQhW1BzXnLKYuV;rdZge@}PlMeN@4aTPHrj>_ zLBPV>*!6B;ZMIR=0!=A52*u_K#$^pl`6jm-1mD<$wWwp#)ais=^cUC=&b-al3`r7A zsll#*2z1j%cXODhl$M?ft-%YfWR}YByxTKOL+$X&OSO6ZfO>mNqLC*yL_Je5apYqj z<+GHQ!Z}lCkzKZ<_oZXZRnT@L@7-0=%CUlMOUGOKW%klGiHvS`h=MZ9?vP8-OFHA| z#I-_IXu2An@h;P^vXhP1f>vvSq1RTgo_v3Nc;EWCB$*b|loYM9*y+RC=%X*KDI$7b zaV2`D?v3b&EHY<(%TTjfTAj(W`hobEry4`+duGDdh4DJP+&4$AkG{j^9=g*p2xJc% zdLhN74{NU2Yvi^8R0PhJQ#6pUGtA>`&#Ub{ewjMjRK8nnpH(8UPrZg&D>8UpKuNyc zxq&=zOKKK@nk;Ztd&9Bk3tFC7QoVv)&l%FSUX;nXOA6&L({?;RAx;x1uY`*B4Vfxp2K!%wXwI2MOt{+#X6 zP8DK_(Q9;o>;S7&k+VU%AKfigDT!y2-nnt5HS;@(#9%b%evHIbWw|Mv4q|oU7^Ae0 z&O4BE$-yhxAhp@ztmRP>1DuLdp+<38jsxDWq5H`DD3oXN#Tr{Ur*o;869JSI>qe5{ zyBsWdd?HsFR;bu-z~JspNZnfcrRgxZxV#m5q#CFL3-)X~0yEkJYTwe1&IpAVfH2yA z$e$d0&(y1a?i9=oc@Pec3aX|bU^BU9{X(21d(}5c-4Pu92U9LT4+B7YXlVELtSZbZI#$nWDAGu&qJhwv%EKWg3Nh016?vlK zJ&S!@FEG8Cd<0Gg#LR{`_kZ&`kh{{uksb34PTaG|_l_pPHra3baYbLenvyMif=Mn} z979&W_STO!n?zebUKSkl-APHnEM;>UdhoJUn{yEst=;B(wZl}77$G*9=L&>ybgco* z!*uNZlqBM+^~~Vmj3id1@G9B#VRxtnHBV+8#F*cumAfM6Kc^q6NAk#H3o@?dD+tHF z^eTzA?q_@7PpONENM49beegnV+IPdVF9PeY!L;q-3* zF0fdP>dDl3pYal0`Dksj$%*T1f;q_ zWgN(DQA#c_R{_q&qh7$dh{dJm8wR*6Z-I!DW`k_4a6>?doJH7BxNL%H1 zm}nS60e<NuRws$yqZXlr~CEowa@#3?fKE=(%vr_rp#7#Xt%f8F3x*UZa6$LnIfY z35or})b27548J|o@$6l2me(;DiKH4>`H`Yy#8YTSS?9|{nev`G`aJjB59h}Oyb~n280XFULfcXRP_NmtKlu^4g&{ai zCO=7Dcy!uWhda-$$M4E{@)G@1s*xQFJUrRB>G9%@);Fc{G{eZg!n!^GkC__lEgwoU zpQ~OO2u!BwIJ{11*;D=4JT_$6Y8pw-nG*Zf7w?F-B-KI4GnqF3`Rmn({CVh3rGgt* z8G@NDJCocqDeALB^`(Mm2q}CEADxzBysgu5@BQKZO1;k6y@m~oGq3?Xk!;63=M(Xw za<`<~%R%?F&uM=&5EH+al5_iIds9`(2Yh`q*_`^5t*yFvW>vS@2M(8Ob$v{R*i2oCP7eJGK8MuX zokNyoZ}meGgAw5+J&JapeS;HyX##8ctnNa4nY@YJucA^H-cj)G7j+&jOeT{!^3J?y zv6rRM&Caun9xV%UY-2wawe!lKJ28+6nIM3h&3IWeY$3fVcL-X@5QyiMYF=I zUVlGRH?DE#G`?Oqw1k+-3M}w6{|cw}?&Cy~WL8(+fNe?Ak|e!{Bc-az{Fc0A#hpr1 zZij9=!(ak5S2nW7!DE0200DsYkXmoQOz9)3MVwmtg<2KbqRm?z)FN3edV{B;8&_3n zm+(D@{8RLD!-go=H(yT|-z?TVx4ZLEWQZ9aFqK#Pa0r~72syN9qI=WcpZL?$UMh}J zQ-TiqEN6Is1+)8J)L^t%?hXtMm2u^}l)aU}E+2eOJScq7|ZO>uN> z5|U~m%{FEP`>GB$|1>2F0m6hj9RkB=(xer zk*r<6!8X&tYFH4d1nw06#*#-S{=fPt8%SKYY9&ABTdBV6O5Ef2S8%p2+RGqd7DwQN5yyPa6tui32{pfCzKq<<=V zXR*;6)99uBP$FrT&-t#j-Q+h$Z6i;MVXey?Jc%83jEgTS+j*`dJ&RsagT5$gWUkV% z@!P9gtX~MBCSx-W`oyx+j)PKUb;I6i;Vg!apV6+ZLW7=dXW_d704Z$No8-xKcG+Ii zmLf_1MTPpTpka{wY^%W&i&@>CQ;A*m;Vt&Ia$kgCg4Y|ICL>5`MVM?Rtt6WlZEj}( zcd)i~R_-T;#-iktludl^{rK^_T90D2KsKBayUHhmO4%ga;Sat(ommKa%8JfQ2&ET~ z&?AMECJWr}SwoU`T7J}$c^5&2F-q=5lCV=8$8QlU{hugCEW^u2tFC8AmyKD}GXTJr9{Voa}%95~eL;mp3zYgV@KpCw7 zYq%!^$YqvUG(re1bb2XkkycQ++CR|S?Z;1oB)i_5DH^z!O`IMHSkH3+4Or!6lraIAgASjZ z#v;X(ZR0 zKz@OSh69?SJ)tC9E6rF#m7sC4<8o)!n7ZP(Nj3IEfMHz4(JvvUhnwzZn3KOnC?vKi zRB9(VPv!+T6edZsrGR}2Sbaf*W{|c|OW&7rfVjZzBY>KyMd$!dzsEV_@B|&4Xty=3 zbDNulx~zjcA6qhj*%R#w;Sd}9k&cl~;&5}nR}e&} zb6R7%F9~t4=!Y=A$vDc8qGpqjUyPAPAj0cx((@Kv#W?15-o+p@BpscN6k_{ldpkuHs=~j5;C8dM)>|qv^fC8Am+yPx=+moVqWj)lxo=KYe&pB$cvF+| zM8Ov-gvippOs|Gz$a;8(gc<+9&Ku@dz5=NI7SaIm=rqvLQ=9;0hotgW&U|hzT^BHf@efPYH`iMr39aDp$%$`0O+0$*#EB( zWQk??D<^7}0h*wk4LD4z>@fnz1;ghe08&k71a$E00CPedpu+`i)!yQZ0+2w}R#CC6 zJw65!_vI&UkH(zt64wlX2~Ges(yb!2`(B9XCBS@jgMq;yrTUp5l-3^rp{1ilFJMBI zP-fk~&}QOvt^YvPVx{m!G`)^C2#uvGanZCC0H!D}s$hnoTjqO)_g>{;)yd+UH$o?3 zI#h!IiUz6_hBTipc#q^MlIfX6@~wa)s?fL3bx~0v^`N<&*vMp5&VvGSE_o7(uItrE z>=qq4y2}#7e6t6@WQ+g+u=%~n1cFaQKozWxkH>?=AEE;U8UKezW+6y`2DHxJP=V;j z-~9_hR}omf>j?sjJz@Bu zr6_2G1c;>t!5wdaFeD%`L~214sfAy+6OqVJ05~j7SDG)?=NH(kUuLKY?m&XI#j_rx z0RqcAm#s1RL~tOA+^tnqlxF`65eKXloi|vV?i#((ZI^Yw`7jEXMp(BNoCuy#Mg$}6 zjqU~34hUxsz+q8uC?R7m0Pd+FA&tn?mBex{Fwk=zRDh$_%`?M}{bPlaRpzWW`F;1e6@Vk3jH072*@)R!Cb)s_P*;MnP1WnMB!rU-gC zjb7Gv?bQHuxH_hxngG^-qL)1Tz+^&^CWEG0Nf1ix&%fxS;bK35ME-y?@W^yngUXoGV_0^?fR}{6Pyd)K}u>_%n z{s0hZwATNDk*n=@6%u^+s0vquPOSy#bbQ;}8V@z8{ka@9Q>O1lArh=y1>(BKbpfPO zfDsWqU5SxeNp)zgyreIy?4;=7reLFwaYCAqQ1X^U)?G9ZkToe~iW+8ku8~^}=g2&i z4@CkYQP3VSyaMrNAEiSxeIwcuFmE!(K5mev52SF^YH2N4Bd9~39J%XRA^#1zpHn^!IoRR>3m5t z()v))dKg~;FW84f_y9b7m7zbkCttfMBp2}1*j|g~$r7-<^BWu(5r9$)x>T^?Cnh9! z|0P@k95j5p{SGkg%%o1d1O(BB+J5y-s$ll3XWO=l=p`)5Qp* z0aQdh62=WofCGscJ7D$uM(~UtI7jSsg#)*?C{qBzm{j8+|{Q(fSf4LkV zK$UO;C+$4|#K8RJEq*l8<@mEAq4x*)DC2M20m6X;x!^p64?bPS^+RgkPaF+vg9h4| zq7JkioB*#)A3(BDQ>JpGqm6?Uxmp7Y1$zYC=$s8J zM4nrP1-XAut<1@POH>kTqw^4llsyKE_`x%ffRGr9(;p)4g^a?M6|K=wjnR>KeoufO z&jl%jtH4p`MQ@<=-^M0_I$v^zBp*p(xRdxNH#*o>l3}<1}NAKky`;5 z1pPH2&KN5Op3HV5XAJ<_9jP#pdh;O$8{|U=ISbhyzc)t*zW!Cv1VACsgTd&{!{$H! zS8a3vri)GxN(R)`{M(<8;FB|&i1*3+O0zC($>PCb4LalqSHrReNB|!9Jp(VeWS)X0 z!B15X#br`Iqz6@iq!#tD3dn9}$sx0xn*uyO3Gdq%c<5Uxz-N_(Y0x0w(M#}Rt(+hvHwd(iDJqu&nBX{wp0%#zP*5HrkXbSYeEXfB>O(3_jGlAw&`H2ep{45%l|u8-n(X^Evh+bFQnA zgID#wV$%69ZvQhZP>%wYJRj)Uzr>dR+n$52g|Ib9wu5}iiN;P3<=toP>cXx8#R3&F z_vYrR3h)roKrX&?A@@W;JYvWhc}NQS+}?%`t+8eT;CKu<2xuX_xRMiCqTKh!pQR_@ z1ohVo_YK_1*5Z8h15j8KK+K38%__+zoEX-R^@oeficB^@SiJ%%i z1V=2isJ(;gTHbtr?is+TqofJp<6-PRabA12_VQgAjs&~hFJU8 zhX%m<%M^Rv#Z)nZq2KpI@v+;YGm5`0n8R%umRi8UZWm9ifL0S?B=xjK&R%=W{g~8& z&Su{=$kx{d->UY2LT0&3q~mwn%Ra>G*}la$Ds}mL-s6l_7b=%BmM8k|#=F?17W$kSerfA3Cv&zv@ZqcP4CuG?}^HG_AlP|&v@dvPbJ zyID7HzxDN%6}jU{&Pns3;j%?=&67fP3eBdwpn>Wv=kJg#Bo4kpKqbQB=vsOQa$9(x zv}#Pu9KKrDWH4^?+u0S%>;)D;fRZ*{u1Li z@4HkN74u(TT8V0suO~t6Wdt0L_|?m=e~_;iAK#HIhKlUBEkHPKcEaU1Vk}YicN|?DLNm zy(0^gIm7}xre8#i3n3h_WMw-KXhk25$!NAoNNrASYi}Zc5e(DMEpR(;nV-W}cB}S8 zyQVzh3pV3E5r`to{mWt@kjA7=T^*dRXTSdewkJCz`y8(#9vb&ub=h+`zZ|S6NS0fP z!CEKDCc*1+a+ZK4D3(o$L)z)!Q3q zg&%!}y=LsJIgMAN0TG>Nb8lEAg7vL7_A&!9Dg4&O@H#Dnaf@)r)m<0wU;I|}V^w?J z`=56<#rn81TbOy@d|*)4wtg8xUS^@)W4#-xzI|4{=vyU4O381vq_TDWa?to-^efiE zdi0_^FR$iv9mv>HB?)H=91E{AL4wN@~`#Q`*t0_`VSiZ6ZC} zN2kqYtxr$O#de5Sm{K|RGwO=FWbI);bMdee$uAC3tEQz2M$0m>%V_fZ>&XI+=N} zO-9@8!ng+Kuy44y@SI)otbW;R+;+W^Shu549DY)=ibMWOSt!NfbFM<2eqVV_CG`h2 zC78gj_Z5bsIed}#!5b#A6gM>;SKEe8jQpgEmW^g}2ZW4*@AZ!1>@}yE_KIQIyr5vKdFcDkL-XEnHz zlFCG-9z->czHZ#U4{KNpsZzbc9Jpi2=l%M3MW0M?B0o$|DBU?-?_p*2DZKd<5joKs z5x+J-=&nrAA}_R*XEPLcO557A09UXGE(`p_crM~rmXiEGOZ6|+!kl@pc_ zbI!t)QOgqA`B9IzOj5#Wh^%sAJkvm=wnHlg_Ax0dbIooa84YiaQwBdJ+g+Un+n3XM zT`$-gdAP!Te*OM|c{U5{ z4@hp;m5auCM@@~nhF*t?UH=-gxkKi{_}(K6UoB(%P5706=DZspw6?+4ZA>7?u1rVG z=cNDEfy=(x98ow{Yf`yTS1n`8i(es;x!b%NJTx8ST214}h4LdWdh+s;5l%Di=?*>} z#Ku#YFU`DY9thqYr98S=lS^mQ&p`Cm#&Q{q57=TIG{4STkDT1jH683}Fl4n20 z=U3wk_BgiO4K3YidD|4dRnoFrlx{J{tM%Dz9|mcM5s$EfyRPSZkuPlw z4>QzI;luRva~iCyM{*Kw-A31^c{YRa79|SRbqD3EvjM2-V5(g4ab4V(kd`fxRT>+v zkz^eFB-(|`%s+|Nug)qDc2*6gy|1xv9}m7E_$*WaCI7mDj0U0bL2 z$nag4WryuaNT3-fhO_qbYtnVUyUDbtt3LiVrKz%VP;brZI7@yjN<{KKVUG@#()?L+ou2iZC5k_k@F>~|84t}tX7a##+u{Nxm zJXF0U8$ZM`Wx7!JKL+gHf46&!h$9v^SP@TitPlnGK9 zOcNj*FPpooum5p&1ky<)U``nM)Kn?t{z=X543UdqEnSK%G=(nlD<`3;Dnyotxv&3b zT;q6aLcdXe;kfo<=+|BgpapH~xN6RqU+>>sHyluifAWaL*l|=S^1R$SMF%;4?d~Lm z$F4KEZ867{6`*lUL(={5PR*t74$tri7)rJ<52CUwjM;Wu(X3j`k`-a>#{G$e>Dℑ4n(*&e6zKG4Y@{ z4ptvw@k4g{8Uq;u|pVrGg*_g!@f796hOei6iin!g`I$J%?_z6|3 z+}s@Z<%2W@#n|2&Sb^oxQpri*N16EFp`yA|*>MJJ;)Flx>Rvo91r>kI6cT;VM+9 zkss-9=t8TT|NHZ}X;C!h7;4f9w_XhG99c$RPy0nT42Ptwy!KmTF>Y&`3j!=SC}zx~ z6QK{Dj!Ql(YC_?FPEkfDOTCkAF1cB*>zeLuy~F+n^ZuGp|I7oj2Z+bwj>5m6$N7}? z%;Fdx_?j$zSllDmV`j?=-nY5}>7(9Z_}zK>OY)lLode~M&f@%iOHm2B(r;%ZiBpiT zHhH-AoDYLJ#ou-MdlMICnqPp-yC_PK27_i$D&))JPpqCim+U=cDsBO1!uXk$AaaUa4e?QkH(2u=Zf2;WW z`PbOml!}li;nqF*1YB@?53-yNyF?3WOM43$D>Iysr<(n-QxX2-TM69^-pmKi$G0*_ zKeMwyYd8nGtfFRp)9ReC;q6s0A}CAAMis6^Vrf@At@AGex|JeJ$I&-Jk2O+qqM>>S~_(yoB^Q zc`&Nqk^_MfrpK69`Bexi-0^VuY3M%A_(xReNn{Cl9NVeEZT%8SV4wSb{WSADv!{d+ z=|Wrr`(KS#;)TgCv^f z-|a4037OJTKjKao5Bt)7$I*#C5@)Nx;7aqK$l)a1c{^(S3l+7Lf~M5FX1=h6TZU)Pz!U#iOlevg#5Hk1u`^mWkmTt-{F34rW=p3*SuMEoz20GKgaYQ zBgQbIvMn`@;_9vM-P({ROX&A7a2V_#uGtogzf}>sJw4 zNlpZIrT4Vm7-p5HH=gHDr#4?U@(anUX4UCp{c7Pg3s)%5>^-0KCvE2ucYd0R3iao^ zSi*%9d<^#_2)R?NB0v3Z(}h2P?NVBi_0e4vLZRHYw0G-c&4yWIcuH;bpYIB5{nW*J z$0W!D>+H~U07r^+G504h-hf*QP@xNJ?tW^aYVBKhE|L`I*m~(vj(^!yK3!c?xvwx+ zP1Q3R^mX(NJyZym(&4RLVSv6kMeO>L?_p=w4;-oAq-!qoz8BY&Ps;5C^dgS&wrTox zmgy+*hly{&cPMqiV|z9WHVu61tp#Beh;N@ICfw&ZC%ubF zSbghITB2_cHW2Qq-uYD$7m_2Mp>!H>YIJ(Xd&fbBdK?S4Ke|+$QlsG9_51O4tYoNWgsOh~^&GB|A!VdoT) zerwb2@fx???EBPtqKciijnaHs+IIHBa1iPvXlK?`NBg)nHb@~o|5r#HYzQ>EhuSTo z&*k@Cc{;DS6JgwD_o&wFOXf?l7^Ey9UEOG2OjJyZTer!|Q#aZ{hh9WE+?48AWN;;5 z=Z;DosT`ogW2!9_u7r8;l-_U_+Z;0~VnWMzuJ!grUyiGMTHptAkbRQopQs?!X*r&i zUMK98-5g1=;WtW2@?LA}4uxt)vd(IaFWS=E@ms0grhFQv~2ec$hVp#`G|W_eOvD0FMmNvL`}o~USg(2?cBOQX_>B-O*0xfbyfY>)TNy@ zey~`qx9Npf1TlR_>RQAw9wm;o$s_h9&H;OY%EL7X%T>&AclQi(H*pUG4-*K`orcQG zga|gQ@^j_W9M2+l?u3NMFY^q)30DQqMnBWW!HE9(opbVe{VoII%aK(ojMP%&u!j>JG~jw_eY0|?o5WJ zFYfnO-WzsADVEuaREs8YW3j-KDxp%sgsMduOl9)~_BT~1lzOS-I!h7pHVopGB=;7E z{xE9!ykBK)Xtb2nN`*P>`-uj4TnBUTD0c|VvA6|qzQuu?!zvVED0xb=amQ&?tUvFAgv^h_@_Xnmp6Q8k*aSJPGvD%9HH>EIoN(=PFw#2{Y8h}mnJ z0iDpVOe(LSMk-zCbkCkOHGT9c-laC+i)T(HvtY2-Hdqc#3naKl6k=X~nWVawL?9Lb ziL<{>t3$gDlaMDUAut)L-Tgw*Dyn7B`;k8@SRu>Z_sH6&Cng5pnwcQRn%62_W*(ul zob_6(+mIs5Vlf6hj2AIv`Y>R6WNHb5EEohuia0`|Y%RRqql9h0=~VLz+o;TIyjf?) zqO&3JClMZDibczH9kZAz_j7~hKk#SeVSBHzswKM`dnSv#XRB+nY)&N4TAdNWfHjU@BGfn}{9rwC#J!Jb-^*I#-IShstquI^EA?D4N_dt5)i9!$B=o`bC`=x&Av~GAgv} z^^c*rSD4U?P7>Hu=x&fsZ@T}3&}L6}mJh{v1dHr~2C6;q1D8LE?74 z;ah97^~!Dch|s9U4aA+69s0k2PYTsdH@mG`Tr)MluShye26GSxf1S< z`TX~+!Sk=ttaMGX@+A_pUnzb|2XB=ur@5Sn9hjQ7)Vpr7H_1fAW77B}2k~W?-tN_` zD#3z*h~dTgEvcU!C>)m< z`gB-nv1h}X-(5T38N18n)5V9nrz}m^^YT!3s-Z2S4j^R&z8pWNMP|evePa-9rCFG- z|Bt)oZ_25}=q_)QxFFXO&+fZNue^IuW|DP&mt8;M6N}8$0M1MDIJq0i&%@k)b5mBS zXANiE;)+K>hcLgyYA&>;eiRP%J78s6>LW0dqt4uk!8I1WGH8P;^n>OriI>CEKC51q zQL}P!V@{=v7$2tIO3HPWUqPKyxJ3E+0R$Eoj;A}1b`EnZo0AnAH3m!NoYqH+du-ke z==-C=V`yLU zgvaNxq<$Du%zF2&rAT1I6O6Np@aTvSA0e-#>7PSHS+J?#!rYx(tKG(uBSKZm_QR652q zLB(cS{)sWmR}`feq5ObKZ*N>~-2Ws}{`J(*EGvnHyria?v#YI{=!h!bX8&Jr56~AnU5nj-n?b+eU2}DM!>cRXX}$` z_Wo-1`QR>_az@9OAJ6*;-|eY{PYSk}aa^8v^7V;8EZQ|fbSn-X8L|tdeDZM8$JRfU zmAl3@t;cvdS1C*zMTT7Yexl4;rNUQh#VbkcjLLmL$oRefuydZ*Hs|||knnR77~U`Q z?)YHcVYaTeTrI7u3=xG(8G34P<&T2?@lAud4Z_CXR}y&P6Fseao9y8Ffd$ zLrw+Tm}`8YULP}Pd17T;C^il#U1d6h#xx9A6iPinyxwa6Ii3ag1*^j^p8IY*5hjt6 zt)=*veCA??XNfw`C&eS^o5OTeeR_NwJkzJ{2kY)JFm0Y!-HpCPY4R|~e<=b{C4*(% zJZoCZ(LH|&R$TLcR$NzNC@3g5H{tA(cmB!iuse_0e0O}h>z7>214A`&wIVuGiEjZU zk*4ofYup1V^m96;Kjz3r%0@DUk$97NOO$+)kO{l}4WLtW)ueN=Ss+e}hqxIgZu^`< z?tA?^BGql@KHVxJ&V#4@si)Vxyf3v2dE&XXO4qi-<19o z9wUm!_N>M3+Z>Upb%S`C3LKvMBOlE_PsabQMv@Q#d~TDAH@>VL;FupE0g7)%lra|> z2LwyExpcPr=gOJ;njP}HqpM#&3w$t;YZrYdcNJt5!>9 zy%t5T!aD1~pItYN>-eK>aRSCZ4C!?gy31zAehSz7rJjpEoYieWzlDY+Tq!yXzFF(Id8%o{yAx5Gi;*n z;N<-BJQd^>r2_!H)1(h!oE6v-Y=TClQTJgM*<@C?(1y_=(BZi_!SJkv_w1{U>{&Or z=7(_J#B_4Z-nHxez2BNRd$PCYaMN~!o5XqLew_3{uK`8EW>vIiS^-fMX@n8Q1hR(_ zTcM#5-iAO4mxu%-vJwc`zX@{uyog-ynZhiOZ^g_2`G#C&Yuam83W5(hWV*U@V^z;~ zsg9D?HCU=1|0y%vR|DDCkoi|noW{Mn1D z#hRgK)qH{>Gnwg$I0W#xCL+*;X~E|_5`AIzIdPEt4B`ldAU>yT^wMq@s$>)YY0ICn zb$iW9528G#+Nu*4=ko1l6c+QU#Q8Q(lQ;QV+eS@3RtujdJfm zq>1TZGR}%yECJf;Rqs=8`{%v$J(4|4=UX*%IO@2#L-8TjM#%f)WLb^L4Mn7S^6luS zv*L(w4cm5Bv6ciKC{9Dn82{ICXv51C>mBF%lSTw z&kl_5JYxdTGfi_pVe{J&Hte zy|O?p0lYVRP=HVzyk?FMsC956EDj5*;zFDtO-BfdGL*#PFu`QBn2c3htF-(CWCKNQ5 zaH2by2+lC-NQVX)lLUounu*>L0lTnx%`3sSEuwTuz``kxV71qX;y^ET`sA#FP5@rx zeh1iKwQG)fKqB$HG!x-7Y0y}R&59%zG`as`W!w!ZA($ zYsg(PIv7da!!FVQZGkZXW;CF&xF(GMCsPgu*^xnw<7nkyfo&6s>n$oFdcE*RMbX2- zI0&D8Z-ygLOtqCpj0!Z?V|eR7=Jg-*`u~-ArP0FEK0QtVu1^|01do~(Adr=EZLv8x zVrbzCw4Z4pbbjK$(Eunv24oZOGJbVC@fo71T?PNihb)q@7c~C8q;#-^x*II$>hbyx zgsuXNe3Hck;E{($dg35Z6*tNR**|V08@P?&d{a4ef@f;rSG1fGY@&+HiVQG;X**4d zKRz!6>GM*;sR_mDB!IK=sYfq8V}FQ+x{e+tuyzlSNYdE#Yx|2Yk%{%YsNK_?ce6S` z8&S}Z4K)Nle!PYzTNILepBtoR5dil%TveA=^|DsYSQqzzd<|S6lijaL-oU?pk9?>( zgIWd#o}&3HK~@$Asv>`sB_@0)Lq#VLM*)ve;C;gFyJ8_v*<#-C_dN+}f@%jC|M^lp z%MJL}nRCa-Xe!tn^Ci)u7te}{im)d#o1nM#bip+7cguo+jQ;9)6}{SyeWD$;drb*? z+rO=L0WEM5ijr=Z_g8nXw*6RNY{7IUgRC9d1Z&WOq6Ts~y6+|8g-v?3CUfIeM8AQn z|NG^C$16{`T+d1rO{5LUp1P^zU4HAlTM$)&v*YcYz*nAoq40g zLDeb9(kuU?G=xhWf$i`u19V<(6jYskQZ@m2>()BI{(S=-0r1)+Xkmr~uMFw7=GkZW z9+`Dc>G=}=B|YRr5;$2&x=I|hGMpoj4_8|ms3<%-@Xrsj(7?<}GPU`b+&`=R`{zK5 zuc3s0an{=XZw00%%l#t-f>0Kq2uK&W{8I+_`h<*J>Ob-Wci2Fnu7&&{0lfwA)Cs8R z8h?Z%z6Rd?Vc(P@w7nPn#dc}}ct?Mm3ITWtiARlvm;$1T3jQ6ShZb_x`Arhg}@e`)YmUF?t86v4f=&|DU$0@ggm$(HP1DG~C z=v6?1y{6?O5B`*4sRM7D6KOvuWT^&!^;5TR{!t2DJb1g38(X9#-Cq$SiTA(eW h zk~f9#AAt?pHdFZ+=TF;uLCDI=CdMq6^&1pEI7Q|AV*&m1#pNz1^CPQ>$kyw z+r|QV%JRTXlCBAq**O78=>Ev<0Q|D|<jRqPePopR6|qVblL?Ch-(RSD@focgZ_>~` zeFu<(Vz-GW{l$L&3~UDXFZep@+g1i6FPJf&_<+1Tf!6@&WTOr6lk^ff4Ozhz8SzFf zUh+d3B8oFaaHnT_{|q@}pceydFnV&c@=Vr1?vs;?GC-07*~+@RukOhqpx`#ImaLov zL?gA&&(D8V3dk=xRK90RzK)GZiq>VHAh`Tywj12;;xuua40+9)VWUfIY3+={pxBKM z@;ath)%>c6BH0e?KZeAOhR(qm*bmWEN6dhGMpi(Eub&rX;GP#AaNig5Dxlof`!EuG zs03nM$Jh{gotm}#HUuicwD!mCv;%d5d2tX2yx{_*E9OKd(SrC*xVS*6<4BV$)u#0 zU!J~0L0;H{%pHDkJZbD5-L<)F_nbLk;vnnz&CTf>C%{j!1dZCP`YNvp$4Yg(lfXBj`^6!CAsr#N zb2MB5Qo{5g*{zML*SQuOt;h?qz}+3aAP+2?h}Y4lC;z9t?+$8u{ob^K1wk*CtCZ;F z3L*ju2pEKjh*1#`l`bG65}F__v=9Xq6(lMG0zsM->0sz3VxvRoJprVJ5JE`^BqZ54 zUcJBX>~Hp;o!yz89shAKgim?PdCz&CbDnd{YQBtL)*j)Jk;)@ip7x1TvVVqbmDZ1Y zB5aY2eCu`p(a1ZgVKUo<-^*#*?mo05p(mf90vb8+FsRCEFp2#BRF%`5Am%W#2$bw9 zZs~iuta5z%qlmx44neSF-+IxARzT}|e8=r%g|zdEU83Xx89P?Ddrmel%w$ncPWSnc5urRVpSOW_ZB1gdacAxEMH*rv z5w=p6ez#M@!rvh*DN6CH($53Xcf%w`QF@cop_^PnUInAaGQ0T^m7`1}Tqjq69!W`! zLc2%vd#Tdc>nKpXNc0(o^H89W)tKK;CTjKbDF>Tjo2G%gmDDVt@tNQmuDJ|)+AKww zgRX&D(wE;{VSwf+rm+RrbH#^)ddwaC?B66A6@%hsli1^?t}rXu-Jvq4DbO6rxEAHA z>7tQSOABIZqR)iTt67C@nVeQ$WOdyBu!OaW;6|-3b#m=VgHUZ&Wry~{fpSF8ObOSZY z*+On1sEy24LKdt|j4b#BeZ0Tkc(S$k67vMIGz&B!2*Y|7yYv?goB;B2&HZ&l&zG3( z{^Fn=7`I3hJ#YFs(ogL@g3vK?{fLl2)0WnK+bj+^i9n~5qIy0@+djU@JbV54_;ld$ z-Hru21QY{u9^c+kP=OxO)@wSwIv(jckYtNVGOpppPl2*O{t4A(zg|*81)Y4tUgr!1 z9sqj0OssszFvZ?s$Yd!m79uW4`65J61uY&BA~r4c02+H-3WyVgOs^C58F2Z~@@1%Y zK~i1?8GQ~W>>?xKItN6iLSuXo8x9%!ee5 zD0fl~ze^%;6sSEfRFow5d+H?NK+e9n zO&GC@nHpjth%(h1yd@(_&_sJ?H06!`8>g5lT#31M&|4=-dV_!6Aznmud1JMP>>V#H zULHp4uT1s~3`KVarH>5S)#+4QB+)MeKRlO#soD$?Qyx7sC46N*BKqw$TH|!W;eaZ? zeed~7){AYa>}lANl{6YM+`FkL3#jz3vc0JTCmkx~X`lheJkz{iLS8deYZ)QnF+bph zF~tn~47MohFvURad~10#`^G8cF#WDk5Q=dEwsda{>jCzC?3J1B+7ds|q-0StALrd} zLlw1hW44n(U;)k0s@1Tl7=BoMRP-_j)zw!eI;ALc4^DXrmuz^Sx{6Ri;+z)I?`HoA zKR~6VJG^P_>$lfC9-Kwu=&h}w6U9}!*fo*U^Y^hn1CCnviTgV=&OMTrFyLU&YpHnh z`;51*z!}zPDNJndc<|@>2yya`z7d+s9WTOD(DkYT6j)bxgXUkE{Ce|RD^Sw=_SFq= zSQ4v*dy+RAj*AS~O4=^XtxOxF%^}DKg=OCQrkGmK!{~EFAmlKe--s7wl;S!1VCJRK zY|ySmny=1KO*YtydeY!V9yd3b>IAqm)8Hs`&5H%Ykl)bPuU2B27Omi^BGsva2h&^ z*GW4>1Wu?DeZwlNPXij#Y?crYMkq?KY|HN?@ zWAIP*OQX~3WOm_HhR6vQG{|=ECsC*I5k8v#Vh8KA!<-K)Z+H|O4YDT?Y`TE~*Un{m z?qYk$1~GmI#&)B1-X13rRB#j#1)mh}s7*?Pj1?ccT1u`DpR6>3TDpU~8>o*y#MYf~ z9?M=Yr#@hJ91-hg|CDRl{~8Pel1{hM)QGjERKKwQoMhFe_$x|1Ll`?yAAUwZq-Sk) zDGex72eDh~15a(VTM;|kd|Ms?^uEtEhrZ=R@AEfoCL!)T+nhUc{{VD|1C%*rq2ocX z--E+|77^$6Zk3i>?AUp}x$b>6{LoelBmdWS{~U4kCBW|=o0kwSsX4%2an&~hH&_fk zAtJnHRG)4}XoU2o_FV{j)Yxoe+M1NM(QA60_;PB`q@_a&t(9E=WRiWWDm98^e(YB~ z%AXpI4Mt$qjR-*3Jro|f;?hAoJAv-J#B8x3bUZk#I)IRh1qx&EWZPR^nMOq9$8G() zw_6;zOdm7{+ER4kPG*&tnUBP1Vc)xu$lV7%zPq7KsTG9mmI(YT?48f(;c9W{oz>C+ zhi3du`NxUQbXh-#F!td&WHF3_qqz$?B;5l5Gjm8L*>@DA(VWRv+cg~L30Hi8Fna*0 zzJuntIU{GYylcELIgeV@8RwiwMrpWUG?xpqt1yZb!2q|iQ315oe8|@dCG-U^j#e-5 z{ivuHeX#mwej8#X-}#&EB3dNS!LnAL7}%lh3{+|6B|Tl*Qy%RTV#J%XGE)UUkGx|9 z@HLQt7!}b8_`acjKfRa&5HrZt<}ZO^idOXpJuQ{oM?PXT_JR(!uXm^oT^T5KQ@oa8 zz^Iv00SX9h2q1+J3G_FP)yai{)k7rLJ*FZ{TtXOg#vlmN6)Yuuw59p?Q&+Y1dMQsH z8)`CM4}WFzyT(n!G%;FP>Y$~bd5KGGsVnFT*nvm$u$)L?i&HXkMM}Y7H=Fi@gm)K3E020&ga;n$$0~i@esMV>(F$Kg zI;-tWtn%{!N51Ly%twvwmCsl!lYECvH3pchN;B5I8PsyWOol-P05JZ0{DdHE3qqKZ z(&AKIEvA>r64_x26&HZcQMnV+(JM))I?S{FP^fRnUx+`q81Q?v3%j8niz<+%Esrxj3v3+&U*PIBjJ=z~TLS<{s|P;zMja8d7~(K}9B z^k!upbsGlC@#fS3Tc0V=K&**p5*4&G17gRL_%+hkuzdj(Mno5#+{s};JAt@R+hx3B zvHR#*w|Fhnlj6Pq%*-+k91j5z^Adq0bEf;g>Oq zKLX*6(Jy%#0G@4s`8V}l6N*JI3?sS~T((N)WFeJ7gY21ZI0Xk}Zv3Sb_KJN}q}ZeSzFE=^eXS@M>XVQgT`!r2k$*K6YZ7`9{IrMY@Ygn!Se;0T7{~2|Wy7FKH z5YveUax?WnSLrASQ*zF%j|=|=ZGQpS^)u4zKX$oa0+V_jX=MJ>CNd^M()c5V|E{K2VitLuAzB6Q;5 zd#5^#u8Tf+2QamZ4ObO@uF~s808)DukXRyh6a3q5Er2cr2{8S6 zsbfE;7VP+_MSH;Mmf#oAvc2~^`MiMlwVOSH`3)=aZHsbn!lLXyVVFB0XQMnacKy0e zIJi!1UP{W3>l^|PaOlO@(4(Y18PfKL?w^SUADGYR5B%UjK$^!YNb^V&Y9;SH^Km~g zM9NmWx<3rlKQ!Ri^6lDk2KtR>*F8AKr-i!Wahv(Zl>c=6T$BA+@L>0qfmi5I@$A_T z!}So5dXLUL^V}C~f{lCqXx#>LV1@19t2u7xzcd)834X$hl6nTfHsz|Hlm$Fh5ukw@uex>Y{c#e2LFQ-O{2XM!3t`Dek3QoM@;{WOY^&84 zi+fI2_ios7`y#kQ8u6X{k0AhqpU2#S)A;&s{#zV;2rjtkDz*}w7hUg; zZQ5D?FTKRa?D$g$5=4GZ9bgwd(z7#<@ZaLh7I48W@6?gN&3J#&e9OTXhrtKY{*fg= z4k0k5H6TkxHpCOUX{S4w%8ARSMn?R%c(4;3C_69Qn>!0qpdyRFBj|w-jxnTNe?-*$ z1p!iJelCdL6Zv~0{}1m-@0PSM^ee^l_=6-v)|I`@Sm}zPqy?uAmllJL61G5xZHW9k z$3fv!f(mb8rgv9e_r0`ls$#IMcv!C?-*_y_si*+K6<=L!*;l<(OfCs(O^&odU3#lY z+u~+bZ7fy~Ip5mYZ{7K*P{dj$pX8p0G;1oZY%J(}$sr zuO2N`Lxt7`sU6NS8=EW=FR7o>&i=zcR+VpiqQD-#RGz=pLhj?q(^cM66N|)+y}q`$ zzD5s~8_#sd%ni~G2iyqE?{e#_A1p%_A z644*>2xlqYJyFC;hyo|#w9bqg!VIx5qi3^Kj~P^m0(&%!}p9?g%4VZJF^7iN*opIb|n7A zn8}}^LL3Nji>wbyvx;mMgP^f5;D!etf5S8+I`?Mij?!1v`9MC!vO2+PVw^@uEqz7P z@@vbkd`$WNHttwUk6E^hD~P2G4GEV>Smhs<`)?UAe61xnLB`+9#K}NK5ee9t$-t-p z83*H~GH}U~*FDb67m%qS#qD=4N7AIL_oNwH zC&ZUh#)rwBAC2>#CPmep)|Nwgwt|#CU|_MpRG%6Q23eBltZUXp7Dq)(Sz4^VL4_%z z3-Q6J3v`Lh5fwD+`YNK2+A&oM*K3Nf;^^NKkeB!R_JwKx$&;xEvWuH4G^kUgQ=P68 zLKe>SA+v}wrbO;zm_E-p94L&)D+R4saiIyPX6Mw>hW+Cwb37fjA){-3^x8Ey{41>h zkyj=O6;Z{kO`!SrRts{xCx5oqJs1bm)zH89Sb0ZC4^|Nt6(CHNYYH--Sye%FGzZMO zCdda}BV_kAmW7xUE={v4_TpTu;MNQoKi*3*YKo9L$Ekn(#kDp2J5FE*vJz}wrvg~5 zOZ`V;K~f&@C$7%LUJ|h|Ohgr2KCt>QzlO3IS`SbtFVHTaJTnX+>$`UC zI4Rz5B&fIJvC(uybZ%dB8xR)}AvG&pB5gE`mS|hS;Ki}#C0UUmOx)Rh1F5T8K|{d^FzI+$E1~S3`eYU>&i*PV_J`4S97l$%SV2bH?c8t zvpdahdP$@^GBAzeVj{iqp;mUpUJrt6=hizueV8F zjs4u>2N1X$O*!rFSZH_`Pamo|#TA9l_weTu))nUt11Sxjk--r6=K19b^>cN!{s zp3~#MBi41p8-wDl7OYTCOJ)8RMd;DD5fS^abk2vDriluX)$RUD8l^U>Rry`ApbjaS_J5 z`M43?fOWg)u0}2yew#wWlRrw=I@Y&Rvrds~*f)y*df}|`c(!P*>^T#r?ZcvYFD_ff z#3L*6RB3rF+xJfGNGcrEMK%n?@2EFV#3!v^Noz9PTdi<1NiZXCa8@A^zktO%*{2H6 z%=7#uILA41K+?Oj%+I-hrflgQOs zO13?N$01dDxRjf7Kn8xXgX2E1&9B4)cjz^{`c+7D%{7&=SKmfD7)3>y^z5s)0~Ri@2s3NN0Q2dIx8jOU&D&M9%Hx%1(RO_u$aU4whn zXm|yY#DZHF*tv#P7fQsvBG46zB=JLvH`W4Z1~2eMAH#`-1C#IYO?N^Q-+nl`f=G#Q z$pkinnfXjVp>*hL6-Ct~C{E);p8|0>HWk9E-|psgQ>|#2wi~6pfd6$GOSY_BCxdwweVRp^{TnPydXQ&24Yq_%QEI(@8jJ&+v=(_8EtGaagysrN3D zk2_$Ht1(Fv9yBB54~riN-2IvhpPHh?agP&GF9KCPR;{+7!e?-5dv^z7k6zw)eY=J9 z5to3)!$c{UWf4#l+-;wl|i`TF~y~{dZIzKLJ1MeJvf6sM4QMV%I6jF=ENl=o`NR$C0$)>QPg z^+Ggb;iN>jQRsaUgGd`O1u1S2)@I)hI9zMK2Jxmt7KU(i@23qooP(gdE<#>*&9CKc z&8~IKP|kn(TtvOWCXME(={xJgl}gBkddFyE^yfB{M@@taefSkvTD$#y#1{D(mb**U<`WZ5wZOlbV4xq7Uoc^%2Z`9 zw%4GN$B{I5>(!Y}VT!>4Ar?6YEx;UGbxby$jH_nF!S~pxAuk-5#Om#%52vU-UTd+m zIf@_~KyT`P-rLv3RYBK@%m=cAt=!}-iZ12z8xsHn= zJ(BxKa@7Kovu^pg{lO+_40S3$#x4SxcbCf1a;E^@*H;UfFyyIsBhyn1R<)hcOBZ^e z@%g?svSxGg0Y1y5YXbv9>PaE0n=;gcd*VxZI$UIvZcriHhKcnHpH<^}xSxk43Te#T zoG&}O1bsy!@&da=%q!Y}`5hzaw!_zph%eqs*5P=oQG43iPjGnzZE3H9tqH-}+26kn z`r3XPe8Gg2&UJSO-e8HjQI|8g9|kI|E3^f}eP{9oSKu=m@B>OAe52mT(pYJYLB=d0 zJbRli`y;1W>vOB;Q=8SWsT#*$udF=lj0z zcD9wN(!dy6vU`L`xz~~p5kL(Wt9~FuQFA6ihg1h`JRNN6@=OjvPQ4#s?SU>f5(`0B zIq&S$K5C+hx+U5BK_^@gH6{f9W_)9v%Xd>df?eWuvCT?(N!~H#gSSbA9knL(xflaz zM{2~PLk7uuIf;4CsS?YxJcgJMiAYJ+%yUs+?FSWrj(2uzYb2EhGLu|~y^h%rZz^>#ZNg-fRqc=Si zdsO2MvcRi@R!T+=&AziWLxt&o4eptaSA1PMSBB#g=ZW+&FPi1e3P51H`Zbr|dGTTo zZ6)g1kq`}#4czx$Wh;Nz-{kN51RJpHi-8Oul4iq&R~13jTs+z_ebF<~&^@?C;#9kB z2V+;oEwENhxPoB}ySfASXS-0LsM|C-NkCO4h*zhIVrNJ8)OvrNWEg2^n1y6(%lQf_ z|A`BxeRE{Hs`I z#I|agy>!{VxZsUW(_5aS1HJE#YkUAHx@B9m<$6Pas5^kEN`I_g8@{{B^y0^Rt9Hz5 zZr7{hd~~d~TQDKTY0N@vS9qO%No2@f|8hj6lPC^H*e^NQcjV)?V3T#9 zn?WM0rrAG(V#yyA9vGCXGj)djIyHgp!#1c&nDtQ*34gXxP`8t8>9j4PsJV>0DL4?I zt7-c?>Q8Ujiv{?B`;vP9B7B9e5E>+NDfFD8O1CQL6kc0-v_d?PIr|hwmA6@I3;eV9 zvi_jY3zFj&=Yda_c@1cgxX{AAmBKW5eR4}&zjrO^mbM%ZeY}s`)zoq!3@t2q!2fMh zl%6i3k#Up1p3Z!9c68a>q5G)CucKuaJNxqV4; z2tN*%>M|uABGQA|vM-)qd4=Vgy(a6dt@oLgi>JO?DjZ9NtevsQ9hSnAi5`VX#a)4I zv&*P}IA@Q?r8Xhb`V&X4MFffE@v{h~;T20VpiuI^Pk5T3{6tS(Dg8v=NS8xCso^nO zT*`TQ;7g@v8v09++Nh=ebdC`H-h=sd6o3vx1WuB#zWRa`rX zY#%c|n4;f1vij}$RMQ)F-g56|m|2dT42OA-7GUoa3Zm=Sbz0}>Azx}8zscCykK+wu zCMV}6CGQ;Jt^q_q?(F|Ok#NEvtD}R86QiO&bduCIA8n4n^JYSCuKj2Zxwe}a_&$kIZfS($e#3;q#LJQloV)=a?6|#@+WML%gPN|cOY<&`)S4^1(^fP2@>}tE zsH4p@fqLDgDc|^$2gTh#Z}%~!se$ANo!3DweL)UQs%C6khC+1X8u3$QY@jv`M`^fLpjx$jMqftyyv!#BbXy7q znKs{;mr0y1P1ym|ua_fvpzk}078R})2I3ulvMReD#6MDzjb%^1|eI&TUTdk>`w04yQ zMIYheJ2D*A*q$Z>;AS1;ql%@O$ei=B%wlQ0YSn<%0f{3zC(d!`e+J2+ zK0w5pk~@PV3x8YrI&ufiT}JgX=B~~>Tc2SA#6Ufs;agg#B}&*4W(%1Ug`c&umkm0f zgkd5V3`yT5Uhz-`adhPT#%}0^N5UpAVe%ZmmsLTZ%as~7O>ZU{HudMwT&V5?QHyK3 zA?HYsIvq>5({_8ytB~yqQr&7kR_gpI)aQ>Z@t%4M;FS0`A~I93xcBy(VP4gYr~4YB zLt16Mo;@?KOAXo6*{#CoM>6>=N8|8i!cn0-Om_?W&a*Z3fX9AAd9gTZ;RD=_Z0B*B z=pHS-)6Qt6jh@p1b~~Ec2DMDwqF(R8Q=LPFmPLqU73a!m+;o9K?fPAd7(FNRjbC$Q zzo%(`PcQN>C8l6*DLpNptWidmOY3QhKD5mLkf-9*qx45u&5|l*X)96U3$O@bHKoRg zb>SBtnbvD>%H2jr#PJLDuJg;0CnBVOlUKjVtKTf-Zx-_ZB|3RrFju`P9q`F~dUvCM z(2MN6ilch^DgW@%djPF{sr>Yjqd&M=J^cPs?xudyg8ucKUl%P{-ERm zA1pV~^k3vTaQs_+B=-EMx&2YhweiO#e*JUs4lv)%NxS)Z3;*Uu0p7e!fFCye`qsJ! zZ@_g&u@X^%CQ6g`K|SvZUJEYtI(x;|8Xrooge1>AJ+m`ePRwOb(N2N{aMHO z^WK}}^Zx6O{GWb5-0zY5z2^R7hyC6Vzcn`dVoXb0V HGx&c153rdj literal 0 HcmV?d00001 diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-root-record.png b/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-root-record.png new file mode 100644 index 0000000000000000000000000000000000000000..2e8a1a6968df9f931e5b97a1e5b3304248fed0de GIT binary patch literal 67533 zcmeFZbySpJ*FQ`QGJu34AW{QLiG(0sgNjHAf=Y*UgLDr=h>C(JA&p3PcZvei-K8+p z(B1Q%QNQXnO0ugt5Q2$6e1aSG-@ZFA`{LnfCG)wG4>$_HBVZ``)k7hHF%HCs_In zqtuGwsS2R*lkesYWeu~M0zSbD~ zB7`SOvv84*nelqhYt-J(?YqA4tKVKyWGFhad_A!x9{4V!K=(n0Q|Fj!YItIh>*A@v`xF42yn))Pm-F+Gyx)s$YKIFYZFV zU89g|D=}_9wpAnMueyXU#4r1npPJ;ADT{nzg8~zmax}sB-HDCPNb+x?32j$)SdO=b zYC9__p5_;_?Fj|KeCqg+l%efQDoJThl5l0?iP!s{*(B2m0F1qrfX^2gfk>xacLkp)T)iK z7uW)B!R*$uH~cYAuFLq5o51j2`Q4Ki;JaRAe;;o`Qx)et@F8SAVCVGYfh>$^+aS15 z;gLjJmNw!0Y3cMQKQ5>+cN08^5uR)8a>`$je2Ak0yM7)l=dDS=_mW;Sat@QoWY{1; z-xhcv@CHexwTr801iS5b_3lNM@n07z$=8E|n}^Atna98Ly7+BtDseNS_ksKUN*N3G z$(S2F5u{s=w=9B>WzLS|Pi{+BQVlwPKYL-HNf!Bn%?^j&fu@p-@F0k*=5Ex)Mg>1t zF7r4dWHy*<&|dCEN^->WE7EVedSkA+Uds=-*{+NE#1vUz8E~{f7J5-H>EzyiP$4;; z)obprWQL1{A4dstiE;>Ut%?l$l^M!Kx;wN=E>FC`&-uQ4^pVu%<$BD9#Pw7wl^L!>rxM4mqWk7^gfm$wN9vLXTI@ECt~z951uKh-F@~3JSeH#z9DWZ?2cf z;({0fH=HI}v5&v>@>J09XZAkDCDKv`ALPCvj(YQ_Pn-*WMYnmVU$Yu7f4Ug=j>YHPSmuu+@zM?k=4h2t{r{5v2_d;Jn zy!_dba#!WrU`ni4E-4UA5CuccXX^%K+~sauj=680B3?`L@s-v$+Hb7iNWV#bqx>fH z%h-tg_5FVKv5&R#Kr=YQp4``#8jer_IX^+#nwgKCExQ+^+uqbR(xR+4dwQj+EK#thy0NRL;D%2#IhZp%w>*r zyLRI(5-*~^Sbhn3&_g@LcN`f~7t&_>rNdU^C0cVND8DP;U}|#8#IDRPVV!zCYaKmh zU*t0Sc$IZ^hY>=K2oq&gW@M6_lGu?r@H$7KW+T_i2GB=W(YI4>Pqawv3+*g;*>Bk$ zJU>c3w%NBl5ZptZJUf=!7C4|hwmtA_DMFdNaDAbFEPm8+N`BO^=;>(wLIq-o+l2ca zHxD_E+(M#|(drjb`nOSF* z=7ZJL(S)LnU#D|X->2SHyw><8%-&0>b~&4Jn*G9!d?gzuVaLhb7ttRse)uL&`XhBd zaDMHFzi6xF4>;`x&e)J>7LDQ&B(r(!q2({Ua(q(MG~!d|oK4uJqx!)OV-$Sk9?pzA;x(mL!;c z+W?a3n46gHyULs3BdH^*Bc^mVB}qPI>sS-*4(FQXkPK-q=*>gZ3sU#^zIdcBcX|jg zb^q!d=ckKF5UTMAJ&GrA5?i>?lNT$O5G}beayR2^WRU$Ld$+x=VX5J|f2%YB=81ah z;~QMnWxduv(`64?wo-<_f1xSRInC45^e+0Am0CLAak=S3h;-O-)#~DO+OncfbQSkR zWqhS>@#zBb4AoPvvS-w}-nCY%+0{e2L*H{|@?%OD?q(@2ryzBSBk zaF8ZZa{3?$@jY?xC7PxH(I8X#bLBz9yRx{qLXkGdrCPN|`%$ypE|Je7({x_z>{Lii zMHQ}1@~#?WSJ2t;j-9&%o5*=RRT^5hI#^jrcqtw|lq~orwp)+0CV!P|bV?PiT4PG? zr1C?*P^0J|%qQ!_Zk>E$#@o*)c|RG|v=@9e_zn7f^|H&SNj9X;bt9I7ChiLhA{zqDNCSet;dM~TVEOf>fm5M|nai?>e z-ZAw>k;TxY1F0G3rk$@pLsmofq9=KV$|}q0kB9a~?&aC#xkOL#3hQoF_KymsuZ=#P z$zd-b&=vPea69XN_lnL|c+P&hKGHGgtbB8SM_klR|11hKu~_4@acP}uW~A2OxN%bk zwNc)%Vd-WqeatueyyMHLoEsAfV}m;jVmr;aNqu~v-<+){J4syHe&ymy?RM*-w3z-w z1Ml^l$Li>wJziUV#?y|Cf~fTuQw#+r1)c`H9$zqKtvb;aU6PL_$){2EC#Us?0q4Ql zVXI+_Qmnq7`yMMa{n5n*@#mB6|P(y)0o7`44T>P)7wNZP~&)2;2IL_Epy`B@56bugR`{M7vM5` zYs78+2{nrZolWR*Y*gHZbLGbg@`z0@3-X9;9zzQZuhIkm4cd~eWe{7uG1ZheQ&z^g z39g|y5Zr4x1mFr6yrppI{&oETmjeg?_kBDZoM1~F5S#pN^9X!nU+=*i+vm?W{)d-1 zgy65s;O(A__fKmGBpLsoYrGZk8;*?nJ$ZTXt#0gSYHH_XVekBuBr^=$AaZ!9<%EMn z&5C{F%B$Vj1mllbKGAg6R8|r*wzuVZZenj_%HwY9fE@=%!d(np+L}5)hq>F@*g1*0 zOS1fKAqK9ow|QA$zneH)OR{JxtHSQtJDS3Tc=&kuSfq$yFqnj+iJ6$%{Re-$gTEwM zES#Mk#CUn#+}wEF1bFNn&3SK&ii+~`@$>TYbAuM#P9Ap7&)vE0oUZ>Frag zS~@se+S|df<32aCcX5_vVZl!Huh$VNj@Z|8si@oz^7UhLBUjTL|R{QE9ov=p%f@4q%pirDkDR0r5cddvGNPrx^j zGVBZ21$=P)`Nm#{>J*!(T*SeF1-_+-3gtMzUP^Gzce0&ydqvn%Kn zO@~X5i@14ij$R^p!vXU*B>3*HS%|Dl)*P8Ga%*}zLdL-&1i5m5{;b<+GO)8&ouAw3 z!_i{3p_!VBulaOmXTryitSgBQa}fv_>`3V`)zD1O|FBZ^gC}3$O3-`oziv~&{osBF|2Y7jAO{?# z8W7pdbNxT4gu@!W{$l_fip(Yj^XN(1$Sl z!GD1+mo?H>DX@A1e+LB``B1hb4n+f_;Ar5qzK?@+2U_~AWST?pk0$-r9}YE`B!y=A z5wYV^LIzEh5cNGkz_06@!=vW&QxgW8BuX$4aYKGjiPR(l17ufoEvHfP2I5JcH%Iei5~U)FD0WyFJ;P{AnL6q+G_5W|QS4x`9fNh#z3 z1XVtviy**-qyWHrr50z){2QE9MF9@9j}F??Hw8a9 z?Aeg)b3H(iLzWN+2_E!5;3h?y(8S$8o8So65Lsf{MFd#*(4C;f3krMx6!hXErTjzb zt4Kg;Zg$Am>Kky_teUMVH3ZNAS}53-2mJ@_z8fwDIIyP(TA{5ixE<@`vs1V3kk0qXgM?!O%acI@iH&KLfpT z?wVDX2)M?*&v;*_%^ zw=({YjuVxZW!FNnLe9`CI<<~L%4*7@FttK!8iY8%LPDITzQwji#%$vYYz**WZ)%Z$;pzeLuEd zNRTEIka%%a=ZNVoD$nE=w&f0B3(dDGQqi_W9L*gNlwXw7^@+8av8~9C${2mu;DQNp zYz}We`yO0>`+>GiZ$E-z;q^;3{BcI+YdlL1r+EP;9o-YbS`i+^z$4_q3DNrFCpu^X z5c}Ew*}@LsZXgMyR$JHT2W#qLnUCp2fa~6a@B3T+dMe>r8$M?F3Adx481l z)rR(i;fA-*=Gmv;=bVcUGHoIBFg$WI?&+B72zCBnZ?{L0m2lEo2Ezx!(8 zAxAZs#s7epA21KI@BHB50I#x;@Q+kck&(mNPgkh9`IVOuMP17@XH^`s6@C2gOdFjv z+N|iPU5ABDS;=ZYIdp4!Y`^icm5+_-VChC+*!m2UwegXSMjp1by$xMIeM4TaMj4H;)op^T#<%g1CWF_#kbm6n-`@HgV38h@ zLOiE@%We8B#SNUMR}D~8!TRBH4w% z&HE7xc^U;Pb`;RmZEI;r!PHr9>9ptdvFwh};`x5Xv~JaJIl%hR9i^f$%*7pRZA#Q~#(-{?5nRaGW#QFwP zd^6Xe5VauimURDXwR8ch|I&7fs96CF{fUR0muXEdZZPdNPu0-PlO(2pjkn<~M(ptZ zh8o}59A`=bwUEVNqwCFz*kw%~4VClpw>mmEZcP;;Z&hp)pRbttO6k?R?_9SWO${%d zbk_4?GdSUgl93=V_tI-IG2SPASDefP7mBNwBYo4Nx&lfLdlpYOL#jf)-o=zjS(e%u z_jR!PH}lS%neZfJel(5a-iWTr&wo^ZvTGf)y`E7Vu`G&sCzO}^-P5{Vjr40$-LTK; zb{D2SOua!M+Wd}i!kzL`w-+3DC_YI#MJ;z>1df)`OsQ2>B=C8Pgm}z8*bo`fET#>iyWvna#$n&%RAAmSLEIcWdP8W}1|?%Ad_g_a0qYzQ>tB ze102&oC!B{s8d#+A@gkY-(|L`s@;?FU6$NFB@G)Lx?|u*x_r_m%bBBVmquk`x#OD` ziG(LVvx+^tkM%MhRs$98xBhgN^9fuW!e>JV@;}hDO0A3p={ps06Zi+{pZFZa7|$mV zvbTRF$daz!Bb<7Gn7Lc6H_2sOu;Z5Gktg9c)tusUCEk4aY%XGBzM7Hd6I1OBq44tz zw^gTP=ams@&M^sOpYJFO2Wgby^3#c2@dn|6}~RotWTup4QcmMbd#z z;|%uV0|Q3~w7q)e(#~$QUX!fbM|4a-Qi2ekIpN)B7}Jq2%v}v#D3%@r%&R(vH0c13 z6y-x94W1esJ-X5HEU7T=o3q*9o;=PfF;q z+1>WAZt^4#IlFbAn5CFK-2k4NjpSz2Fv^uG=SoRq~n;BSl zK2j2nnLxkdhl;KSwNoTt#bac(=U7UH&ZF64TYDg!DUy*6-}*pvr0z zf0Tcc_Fdy1iJr3;?Qo@74!fh$J+?0r^IThGsSsUs(k9o_ut*4TO+8YvzwL3JTGa16 zWX8YJSc3YpIK9Va>OJTwa#ZhhX30u`HzH-xOolMUc=7i-8)coO29%F;l7GdZsc|_9Z;vP^=EwcBS#I!jdBo5#{?b|= zjhSfEXWaVH@o~)*+JxdKz#FeN{8ia{Hdgur(^6w&8Mohayf1gO@lBq8@kHy~+4f!E zB63GPcAO9&*Zu*ih0v5Son^5%Wz6m&U4P5!OrLP#^8i1tYO-xJfoR`55u~n@gOhqj z++IhG2=;n=M0`flQ(jgI_ly}a>g;NRfta4hc@|vHWtlv1k=Y8}v z)-z<(dc$5gTBUYpR`I-YcVaIMH1hh&VBX=mCREgsxy97L`@W?^Fr2Wb?z=wQ&%D)4 zy6%TZxRQHkJzZJ{bg8XnKI+GG9IO%3Lb)_;PBX^TW9hcjep7gSpsr+AU zUKY|%$j2ak*>S&fPj|pZs%}RzuMmSCpH7t7zqt`Pk@P|5QSGFcW!yBHd{rqx$cTv~#xxo?cGFOGTa zHYhQ`s4qh;22S2h*e@6G9^M#bZ}JWBYSYhPG+{@N?W=3M?ul!3mZ?<3%WE!LgakjI zIM^2Uh+U>dG&9f`Sp8HvoS|Hk#pL^rjB0cU8$9MYpLaz>8Is`Ep_|{26zu6p915Im z{GdCK;uU6aDw6ixL6m;HuDtkq+!HpUx(o-hVHpF~Zyox7TR(*q#nn46N~_ik(4H>T zr{P%@#{-WR97#Qi{Dl-pWW1U&i?8O^b{sF@jeo(&)+AYtqc}gtt96`O9hc+0#Y$-<2en&^x}4YwksQTj}V>I&{Rz0bp3=~xxvd_RNcr-ERM{^ zwOP{Z(hURlmX~sKTnRUBO18$YcRky9tYdspQ05!CLDyzRo{^n>rVqc3S8skg)sO!3 z!`Y9!+hjh*N-Z@CkZ5^CBKiZ;s3UCxc%_kS6c6`iss5w}UI6F$m37zS^=#m?r(VE2 z#(!*|lp#5e=EkYh$CRb`OS{&+89*}_wj6+{#5A{MqIPMXj$FC_0V($_x1379LEoIz|}DjvAxHTDB#!%SfbD>OGa z>fhBbxW`A~zS>{2=5pxje`G%>e2_MI-b=S3X+{FE6R`@c75qtHFNCpakY;J6T${Z; z`q_w&Vrdd%(0|l<&*|3LNNQ4gS7`;_U^ivmBhtDcZQHWXyFwK@7hUbH%EFQ-cuRGw zFCzVJ-eQD8sC70kVm%WaqzKrFZ&mvoUQdB*QcQ@aefuLLLT^{dXS#5o8Xc!P%a6(3 zJQZz_Pb$Myo9Qo(^_(`&pa%o$#z;Jsv|-5=JvDBUWNK*@3p@h0m^pRa*s-Arovpb5O<~XSwHbb{JF3KI5zPU-UNqnhXS4!$=G1x+{H132(O?ho>G%yxgk{=z#Z2)wu z*Yj%QHG8NHd(W62f7~0i@A%e6XbcajVJG1Bg0F&g_oa7}zpz1is~R4zy^p6(v$%3b zkW;;ZH?2$|A}|vlS%~UC@<%pW(dDx)etJY@?E#D*YTSJ0cewchq}|@9j++>;VAF$4 zP>Znhd&-OMJB!^VE&~=ZE&TIk)+T9X&&mo4BrIN?E-qKB?xb@+`|#O5G`yvQSKUF9 zjho`r){l{N^``I9>JV|&&cKh{6c0CGTYk?Mx{29cx=P>F&b42HEOmG`-Y2@A3=u=NBNa0U@sX%!`Z7F6tB-URw9mr+ukwh1B}ZXK}H&`2^VSvi;zZbBjXg*B%OaoCjhgwMsWtv>cby`sEb+@sL3z)Cw47vX52L z-Q_3MPXf|RUoC4o4DAhPR&0o|=)ejMr#BpjspXE%=Lhg(l(+^SF$Pq;++E-VF^}LJ zdmkBOAb7^CD{?@a=~vkF=aWRo!$^e7FCc=dN>!zWO&ub;Lb1)#)lZ*U^mWdJX?xv; zsagwmpR-`&>l;}7&l2qLeTq#$>cWzx`FO|Io($BSnENKsJES_stQwi{@?LDI}O!3no{M4?1bBm9fKgn(&mJ;x1-UNQ)wtw#hYq?$}zZP>cvbwm9uxitgrYBHZBg~<8> zvfq2EllfJ{-JR62Yg{J`Yzx(EhDE4;+cO{EPY(%djZlBvtSHQru6d}d zL7Il=11%=u=8VtzI|FGSjHIs6=<|VyBN8Hrl!r&L?s!#0gLeq2_V@&w)zaqN;Ku3J z@%(CZ!3RNMOVd{1SGk(J!uWr_oB!MXT`HyQY?`3wDrqG|WrA|lCi?iUR|lVo^$O%-s#kBE zXp8sN z7YukP&2ri>j=OK*GcNTa-g5;^Ul9s&B>x!`=j{#1;Dqg+=X zcP4f-Wzu2LVZ*epHUC~%grtg8^7P@2P+;?n@`_b{`!i+OgV2z&ZmDS5Uf-`&*y4{O8=b3}nl`g$ZiTCtthU3@lEDfwlaMo1d8(JHB;`Z#HFs02h zl5u&;KPx2NHEME&m6-yhcZDV`1w_|=hWLLxTlAEyb9;F_OA;1I*1CaK*+~Sk-HxkN zpBE>M(lxZ3|I)kKssq#Q6$X`wXr1-CI(oze%>Khtn0?&?{kROY!}xWYA1}r27F5Wk zs^*!@+sq5jT0{H0&r1gyc7KK)k<6ceo&zZXwqw_6iSZ1->8j_GTh~p`3wG9)5=ZtU zZOJS)x;|`YHifu`X1HW%MZ@}Ikkcxnt=p~c)Ae5$@iI!H zgx)B(8oT}5%3Z^rNfZ&AyLGUG^d^T!$`^od%u%b$aB ziUgqB&cimG&p}j6l%wiOhzq%k&1R+>J;0KU3yG%&Cq~tt9Pu5X1VVY)x^(t5i{;7*#6>h5jTZWX32Ox$DZW>v}42(G4 zUZ*}NjPM1=(WehO|D3(}gJO%MmIJ)1$x@vGv6~gKRqg*|Df2OA{#<{ z^X#(XEoR$^^2ir+EmWoS0@qcu8*MA5*rE-5J(_a*4syv{TwMCI)Y)l;Y&4tDhQH%F z>_;O3IGv%fLh{2XjB!cv_9yJ6kA4O-y_pW_;Hgn^o%UEvi8hykyrEheFU#>h+Z!Pu z)T)*wZM;Cvcq>8NjmLH87C3hQL*^6Um?vkS+R!6Spi1!~kB|4z>?AZix7>rrE9`l% z%Z1)@7o~JQQa96ak#o4HBI^ObwS3yEW7p{72)E%s{;yfRzYQLG&7CsRFEj@EU2y;?f(=yUY*zZMp-Nfyf{>mYt+3U%bc48owaVvB%;dVUhWKwZf8apAaC z4k|wSHS|6+pJ5}8$4XYlUE6HMXdYPuGYTvG&cJ_IbRUF>6qoMA$s|)l>QC0iMb8iC zOvg(tJ=_kKzJ!(UEF^@}QoMrC4yZ^++D%oXrux+sxmjuS-9I}o^)heNcK$hF1jAK= z0>E~5zFQV0y81>B~idUbSW%d8gnXO{G-f#pGrgbF{8))CCkym=Pe#rU&93< zr2&mK8};Jo^ni*XiKXO#`P~7PijBQolD{PrKqMSMRMogeoZ=3q;B#BorGqSydo#FB z*b4VCDf%4mUaDh#p8oT#K=*sVaFKx{)Jat3Ez{jQTS6;*UvEzuI+t?P9&u+NS zjCB&N*zQu_y+;yT_fO(6{|FRP1PH;mta1)6yin*813_uDOM%B8Sde*7M7h<_&9p#C zul-28Y zeeRv>V0xUL73RWj_@9Cku)0?vK+Scn-Z-+%zG{rKueWUW>ygGxMm_9#yF%Nq6mXu| zPmK@g&Pm8^YFg_1BGAr=+(GJQgdqu z-rOePtu@3vsz2M;naFr~!OS_4_DyKa-@Qmy07F0ZH!p#HlbwyyvD@-`YT!2IVw;2G z`7}&%?QwS|!S41|Tkg%xX7UMTx24{ua_U}c{(qIS`dNei`9n~YOyeaar+WG*lTbnZ zaW;2&;_{$buV)6%1yEPTy$MvQ{X31pEvJRfaH?mTP3LRQD}$T|ca>y+S5kow7T`0# zx*FfbOhJewqGTK8gU2abJ6V$A0Yn-Rxst%oGW*ugH9IShAkk-EODCO2uX?m0%`{4@ zC5B&(;_p6W+5)5>&qwStBCjB|O3cPeVpSxz0vQU-?+9M(6^I99tHw7<9!wes;MTqS z8Ec)-d3L<_F~M~_t1`0a%3suksXhS1@EaB1g#5aNMfXv@*{n3CKFeBB#OFX-YzHe4{L?6=Z7WJW7ZHg$gZ||on$hZ=YpCJ zL8;9czbT_W*waYK^NIo2{#I_40O>WOX<<>N#M`Jp z$(OV~`oW$wdM0@^{}BcDq~NuWIs2u|s7CRPT4T(*)Y-1O4#bu-nft=ke`#ALpd*Fa z+zTqg!soiGS+$a#V`C|zzWgvv^=<5BXnCjQOnqJaix%2)%AVM}hUWj6(rm0~pv>o> z_j&nzj-6?58wJ+x_A9#1P1U$XOZgn%H=rngg@d;Z5UXSJXESOs2@#d99M>LWp2+F7 zUd7J;FZsOzMhfRExdfR6xzeK6)v&NCr$vRGg|0PPc{}!{2UDtV#;JNkB?MD<>{Q&_ zn1e^_39K{%CFTA|2Qm{Fpp*}0;?O5r@SeM&Ow!X)Mh8=F73M5)g4WMaYDFFpicN@J z!nfN4Z zpQ!8pGiV!V#&`_5*b>a^HT;}41|Juqd#P7SdUxZnV#fDl6t_-0FpH)WwiPaE?h)YE z-@AGlI4JM^6ijF+pu|g(-2l(LWB5(q+_9S+P1Kt7+NcXC*`&t>c8JeqbvI<}@g8~}o|{RH{AfJx)v^W3$d z6}F4#)($3Pn9nWgF0SBoF3OD{`b){L>tIbu)hVf<0`rqJqTZG&S^2HY(sW(T?(zUT z`VDJ_|1#(f6XN;Ew`@ml*`7Ksi>U@^+(}A_d1vP)&Buw z2Mu5@r>Z}jK5cOAYX|r3g*BZ9|2K4Jjr=j->$)d zM1TflP!-_3RAL8{PNw~-^2OgSD~_rfCi(2k4@wma{N>O;6{}{DcAm}Cot_Ck!~NB< z;`M6uI4>s7uG(^}qM7&-9Hx&qJ~kUjL}~N6Oa*sM$M0W;unmacdzDwq00xpP=m&Jd zmJ3@My#UaPUoydh&6C;un)MHgJXPO}u+A30NgIXba4xOL-}(Qiq_mL%>C`PMhjnCh zl8wV8pY1Z?gT=n**=(V+Z|nVh4+T6LtmwXB6fbRl{6ARs5nve`RSPpP#HE%4t6EZZ zQkq^ABfJLyEi$gBeYpyS&8A?>pVx}+Hsdmsrs13sS8A&N6*lAwNU}U>AmhNrqb1Py z*?a1x)d(d27M)EY^31=X?*W}@d~`UV7GB*5;K=m!aLaW!T#k{GJ>KE&@vYjw4?!TW z0Z%@2OBknSBfYWiV490Y&pDi17l^kFrQtD&LrFdd6&?@C2Q7x=vEDDgh`BsYrmXHU?*Oct-z+vEa zTI`PO5}N3Ii8tPWgXPw5xAD)D6><<@r*8yYApO9e69?AR%5{dcYe|hfjgx0yqQ`F2^7U_>qJ-k1^+AyH24$3m}MU zZBjAVG+OUj>>bO?zCW+Us(alvhbPD5%^)y7q8ibkT)1^BRTnyAjHS_0`kt0r&x5E; zfG8<-o)=voqUt^9Z|5q|ow{4-f<^Yf71!snKvM}{{U9J((yBb#S)4H4wZj8})J=;c z?bsN2a+H;LtPd+i+j?$=WP+quxrIpKpqk>CDx8Uyf&&}!J&}pRn$DX5z}~k;!(rqU zAMcSY(N+SY^rAG4h`g+uUXs$i)Gwztzd@1tUAtgaiV`}%pO=$U!c9PZOVdyXYN3d0 zd%MvO&I!1ewp4TrY=^1`mJ2NqJP*H5^a}8vS`UNbGE+DZ+`HTw9Rio2gN|z&W-~;P zuT>YC6-fz1^n;v?^E<%+F?v@+un`gqpeKV33facFEt=t%_+o$$ZqJq3WiNPf_N~q% zegq5BPo?Q2KZp(jgD>h&r+&uaj{Q1u+dLZp$KNx?5#S1J4}y9|Mi`qp)kAXpT}8o> zc{5bDFgWOCB)YBu^pfKu-ze_Q&+zB2opqqz)Ub%42WvKIfFHCy2%=bQ*DKJMxVz&C z;T#(e@sPFmJ5`gx97sd6eM1hoTxQToQQ+t~M`Z`j2KJGHM!nzrfA9CM>_MRTE8$lJ z{o%AHP8BmG~sSXjlpwA5{>Taz0 z#$}z)G~{SR;WPJr>H^8(%?DTzo&;`io4WLG9>=q6cJMZgl0mAJU_oh_G!Q)5q9Ro? zKnlGGGutKNK)6wqC*bbh!>icTh6s4*;eCdf6*#dJw2qPh#-u-8nQjb3MLSDRT(?2= z#=e;7l|S_mJ)DOE&Uo(X1x4K&H|wTdKDaWwa;(WnJuo~EYh2gftQVbt2%N@8#dWDy z=9-|@6QkE5I;SAIEHv#+(imG65(X0Tgg7`z@XDBQcki2)Rth zk9&$=XrQ@e!`j=xpla9|v*wT0*jG%;!p`jQ$R#%Y=)G2p&Z+f1P*hrov%RyC=SdLB z@p4=1fVjerD5rW54yaFiZAjKA`1k@pD=k;b2cgb(z7cf(z8CiMlW}M`u>8UUx9-B; zhX;kTf|D$m>W{e~8e)saEJVk1yR__92T~%J-eLyFHfGjAlRau>>3S+B3Ag1Ep*M4) zNci4#ferR?Gbr!`-pgf0;UNLet5v>cNVo(n~5DA-kG3}*7jx+=2Q;r>xyF21@?#&o$kHJIT8~Ge31T$ zx^(?P+s&NZ%k;{y_bE*wj{lH5EL%UP52|UPx2Ut`&DZQ3&e(txob0{*gB@7<_uuKd zwr~BWmfHIP)=q?i=Y!_6UTG6UN{Qyo;%pa^zSD|2rqa8PKKBT`DJ)8cgntq+MG->) zkg$D`3oD$T))c{#6b9<_+=+mI8P97}HjlZfFX#qgZrT`sI)XqwS!#UYh5^v45hEv4SNB_|n?-Cd;(=aBF}XiyEL8^XP(xD8w1spg;R*&f5ttlhpHv zm9T)Z3wX#UF$&(p`p$i_zr6|4r#C4$7Th1f-fJJ!Vl9*|IOA$lzYf%6{^u*_ z;tr&rK1q&wtFxx>5z5JRQv)2iUOs$Gh}Q)E*~b@Pv9?!o3XZ?*twx;>AmNSXKjmW} zR-6k6OL@-(uV_{}e3m3z+2R`j!X}?qJC{NR@lWe@-~+9%E#Lc-syMD6dB85%EVT@ctx&^f4k%UfU}a(C*O1w z=)`Fhb+x4JLiPsIP%o9Y)f)2VQm?VYZN$#<#LWfGIR@#yby(Bf9F2kZ^DqHVo8j)e zWhfQT0WZmy=mc@8^Fw2d(}OVin3sjEBpW5HhaTyzzf`2p2JIBXc0B3z?d$f6%YC=Y z3z||GDfdbg0+1j}+vY1}NxOHowp$GS@G}yAJ1P3R?;~qU-?E{eCsTxZ_)`ZU$JjR^ ziz$kjtGe*xxs-L?yCCl9ifOL=%DbhJ(PYP3xu&FKO&gb{4>>{?j~O3g=(oyyu4BC| zKYY9e5}bQvxI3uq?$gl%0qNZpC6_UTdHjn9Qy{;fUICDLB!(&c+&#gr+iv{Gg?3}6 zOL#`ty#8uh=VD%m_s>kwY-a_VYC`zhgL2a`gO+&tyf`Tzmt^pEZ1s`Ej%u(W&9 zs6BtKxPvE0;Adcijf2kb^x;}O;{0fDVaPPm_jozN1FY6UJZZh;$kB?leAFmJYzMz` zdOKa-D}e~A7Im=S;dKWoo3QND@QAdQ-!p7M(dtdr5r2$}-imW0F5zGdZ{gnX7t;xk z#RPO`SpVA&0|5T#{9*-BUC!U0n17`})JFX{#VHUNX_)Y=Mn&nYC>fA&4%9j3oDi|c ztD56oMtG$gw?uf9M1FUYM|92``<9-d4;Fy6J3%W2`*BXRKg|c-c;;%S`HwB?zYR@< zR#65u#2@i%*?WFI$w3G-*=9GaQ{v;HsCy5den#v}&qPZ_Ip%JKCUx8@QA#*!(meoD zXnv~OyKJ85k$y(UhwGSdo)?#V3uS*Fqwic>WvW}((7@LkGHb3F#wfWec7cpu)7&5B zInihQ6U2Bcw(LtOJxm_+;bJ@2tGfef_lBQ~i73&UYa>kc42qJXwO`(q&e;2upOnY; zR-QBIcx7=C1bNySttGSd)SliXJaqdd<-Lxi3}U7FM**!*HjY5#q`oiPLH&Je64HP= zS2y$d9T%8hdC#BE6`r$=hwD^6hG`t)b#3BhoDR-AhYj&MOw-_;sY)Nt2ArX4kN725 z$gN3fYfbn1;+OZL1w z)bs9XQcC!)6x|*O>z$qX$y2kcmVrPty;$xM^t+RXR6BprOwa3|#H~$68apt;;GHME z4b-7KV$GfBI8%C4Ac(rj1(IY=3{g0%9dx|1Eo!T+I#gyqBem#j9V2)Jx^8`ZlJS1d z<5SPLQF~gFt;~!~PZuiz=Sj*5)o-ULJ6KXE6gWlmRI?v&cYefJZ}QNNg5b_py?wTJ zyw(Go6H{*A6S})V691M~pd|?fa&t6JLSu+LWdUrEG|>T33c>LJaqa2Cir7cdkKE&m zg(LI2andVw;{$}YzUJEOqh{&jcLWBKO$W9u)RpT}o8slS%?Co0rrqaAy_N$)y?6WM zP#|?$#87ML2jBJSysLf?Nj3f6-gykdz0j zIZX)Em0q)@<&7R)#E$1!I2lT0*hI{C1%-c1Yb2Gmmd5W%q@6AkCQ!3h9(Q&kZ2s#^ zylJU6Bf&LxcVlM8;8y1r%Y=D|{&8v&ou~WDCy&V0y=g?UicMye_6e-j5nKzmn{`uT$Mc8wE4C7DmS|FRp;F z!A6>-Q_JNxvQSv3?m&gPb%!sh43x%7^D2|vK1qP{`t-`6u>ti#N1jS25%HK^&6-+^ z>kg8S#Lr4raQN&*ql>E0*aBF+I9#+~n@N%`p8 zBZV1vibgzk-?|kpTYHk=?#vb00bXv8eAxjR*YfG|(j!X;$%PP?1R|c(qyCvkj z?=PdG{JywhoEZifkQk(-WU%NKkRDV7C8fKhq@^2%5Jdz91d$Y^yFnTyq`OP$2I>Bv z!SC;0&szWWyn9~WFIea>*SW5<&p!L?_O9tZ=f?Cs zH$Exf6yx+9*zbekvgF+;{S+Uqp>uA*#+{JgdSiD^!c=U91J=Idp0$8qj!>%CO&S1Y zz^5z&Wh44=#daRQ;(c0RT$lYe-|Brsgc6rdayiO%wFStCk59kF*ezFjU8X!p9C(;K zDa|{KsTD_Q_{rk_bpwdy{(4cEgwwus%Ek6xU2iO3B8voF!0u{H{%p*R+Ucpd#EY~I zpoRw-?z_vWLLzf^vIU-AyIq27T^SOV0TO0>`_3QMWJ7G3j)K&Elh=d;7Pb>zR|kJ_ zg$t=f@rLUwlb-ElkNm;&_3~Wnm99-6;*%qR$t^v-xS!A(&!IS`U@rz@35D20P_X>F zB!B5(u5B{7)+1S;xfY{yZhWIg+@=2fV0VOUQvYOk&xU*#M|mW6<67Mbg$k!$1{>N> z+rv38d@A6W5p85ow*SXFIJKa`nGo z)2=%aQ999jOb!Bz;=CQY_y8!mY_k(dhSY>@V?`@yx##vdGbj>Dr z8OuxHTx9CT5SChvD9Bl1`A*N0?FKz975Yv*PN>3lcl$L+^gb?S^0aF!R}|Lka0Qi% zajgL^YHox4^9?J$=Qf(W7?3F9$8SEgpU#R@K451fni_*m$ul4g& zeGgXx^p#)apB_rDnaX*D5)xO1Q5i|-IC-DAXSgDICBxV5g%B`Q%XuT6u}`7@POmm{z$NL*uq)7Q{ZiQ41J3dTs}u>tTE+qKC^ked_rw}@ zLn$0F@TI|axD-J)@NJsSf5V_f7rInG%;ce?vOy*J!*QxW4vjk5DH;VEK)*1b*Slp! zbUN9z@9)}%L#^cLWS=;7r=Hc>#F@uHP3zuYO2_{R*ef3fU1@dPpaPMsC*BUGQ1^g& z)^-j@@LBW+%?FwcOT<(bSmarM)PTCVRba%f_k=ZtZdD($U_%uxYE> z&B-{$xJZw=+E(4J8_tP_ks6KynbUu#JwSPc(^urX*sf`Rl172mUVHL6lUs+0$Dr(; z8ux%0LJP8ELkXN|Q6q+ZPSWXv{y1MtG7q~i0*-_C3dRou1ob`rx(9Dp9Bg5*2baU) z)53c^-n7m4d4~2a65LO3tUp3!U~EtKCt2J*@3oCMeTNj&7pUY(=t9iUrwlAhs;T#-OQ^lIGqo`+AZ>@EoF2cBE^Ia@MtO_$5{uGvYS((bgZ zr5@OP(8u-O*k?l2TnoYXJa_Ndch8z2(C7WI<*2)ofr@nzWh;*~_D z!OB*WaoEzaxYawBm}njgtuAp`fB4cKqtfPW>NQN`7EFE;vb&OG)i(IBLD!e-gli(% zyfr|6bl=){w9(0L=`hq_lM)IFyi~Qc{=~7}iSxo%UpoTDex#YZ)-EQ0k5AO=FIij6 zvx+O@)GS0vPox*Fh>cF*U!HR5T!ZKtoZP(;bi=AdW8pl6HKNx-lPZcHwx@Me_C9QYaT(kG zSlsZxtOL$&QXrG*b@0&0bGrwXS^wnuqZHu=JQ&k(v4ib*+P^5Fmq)D=L%p~Ah%5CO!`_YTBvX$Ov>Z4&F@hv2N3_X%qkxK*0WQakc!y`@ zL-TY=7Yp5|ued!AtovqD0G>swfMtlQ;sjgwRI2yzseP{xque`)2rE!HFZ3Wq5I)SQ z4)9&v_2975OTzuXxLX1SFlZt?eBnCat}`zc-Fu1lo6dFtl@OmHlhxLSxXe~%F*6Ww zT+NvE9%8#av*Pp63!uh=50-7L%Y4bU_-g2YbBOlSc#WabfT4Nmd-i$7ppb$m?idF8 zzFr_SLUq?Solr6227FnpqALM+eR@@FtM&rMyYADifV1EEe&u|I zGnz}Yt<=^|_asJy2v)PEzke;cT(oW2{aXd`My^u*`r7vz#!-ofCjzYWa5vL74bB8E zF+}CX$(kuhF72y-+<7i?|Gukk;XuEL;NZug^@0l&pM1nQ1Hrh)537ROd*fU%H0p`Z zlW3%p1u{8~6&x9u$?Hs8y-ZVB97eMMAWh|La>7l(7Wg-N!1HX+PFP5EUyL>WQstSd zd?L*}f3#SD+<3}}_N%a{b6D|eL^VwM47>QOb0lr>gv=d-TsFv~=0R_SV{8*9ZJ%*H z1RvHf;?+K^;*=;KQ>)TwX~3I&3R29iClc{x`#(V*k#q6OSSb0JHhj6Wk@z|$Uw(3X zVy%*UuDv)^4)`?r%@vztoD^-vG$O(`TrA!sb37^yk~|SeA%N8so3X_xD3ZYTt}TGt z7Y7 z+uP|z#u-HOj_waQ7Y1@jZo6sGF^{#pb3Q^3y#+A5NsouwMEnIe zAN@j_oPzN+^qyIIRrlr+pl2K?HD2KDN$2`dw+k$D6n36}Uz1n3EXuFg9H4~KsGAVn z*ppr_sK2n(A^ujteiUSoQ46B`;>9mk(F@nU<#UbP#RkF3NY#+cU-x0ykPUOG@G+YB=(Szt3N4^Kkir#Iq5i9~%Nt@pHij-znFJc7n!4k<4zQCAmZigQ z+N^z(j}y4K?;Qb5$fR0>!btHqy!*n9lai8qG*87!@+5lY?b=-~Kup~=9efH3@+AfQ zO1;FtFvpiitrw^PZFp5(3`CPjM(ScF`L{f;f@`3}NqG6fqBYWk#153-122eRJ)7hw zILZMudPx_CN(NKIyFC&H1&^M1lYACIjc%;^!l2?fitTrls|gjV0gtVck(L+{D9a%d zaEx=73SImW%ovPHpCJCig?GUb*~!18mE^k8pEkV_+F7>XEUa&rmtG;m%Mf)Ty6ABd z+ z9#_6hJ_%lx;UNHH5O%bC>G@8)mOrJ+DnMc?kY|7Rqt7*#+OFi|Vs$(5g2XN=1z&Fu ztF(HMxpw}p@3>Jdq2G~Hf!vFJI`dkAWS8sQJ9nprYGsZoy8&PJcfcZVMY5d;#^L?^2frVUe+q;nSoiX*$3wt52YbRo% zU487k)d=P=0Pr_!!v?Y(+G!tDNfhD8uhXJssn! zJvSW5*IUC>(j+wGlZYGZ})B_#9wOpu`@@cE{KQHpv z6nPXb9WoZDsap106~&LgDXjg{F({X=4K7=8>M>9}5h&(}5f?wgQSOM~!0^1LFJil? z*W^0h=ANb*y&sMh^cAW{t%hR}8Pz!BF*u%S@#3RXV0-7EE)H*a^(=VkuNK{Vi}v$~ zRY0xDDV}MCGpw}od)sVw8eeY8v$0;<)erW_a>xQzeN7wN9xc8J(rSktnG-U;m*0Al zHk`NJ64|cq>*)$mvqFa*rLSeil0bePlx*qqH8Jf(-pGL%yEDTzzp>h+As+ugWVtUr zDI?LtGQU|l{iR*!xb^tiXlWNy?c4Fx??P-wo<+mmz#T2I+7+)2>#Lz%zcsG<3M6>8 zDGwRCsNMS%!kxIy+oVK1+wU*${{}A5=Y(%v1k&$mKJms)A8LAf9pXBw%XMnuY^80e z{_+|tEGT=q2p}O_ir2*5cbci1&)mvVOS6&%5=aE*4i|?!&8_ep#@7uBr1x=cwDY~M zSwz0tDi0k0c>%WCf!R~{!8I{}k+D3KUt@b<5gcHdd75L|FMG}Z$L(;@KdvbmF>nhN z1yCk@+3Ue=9%3?C`Kny2Rv?IoSUbrQQ)PT-J3{L}vE35nF6W!H;<4Y4MZY&ai$P#{ z>swc?H(j18&RBV$9()SE>B0P9TzsShXK#E~OfCJnKBcB0?ggkTEh zVm{0JTaA*Z^DI1qS&Gjr0?F`6si3RLLa@l4?Qovxh8#1H!xtJ zH$DexByrZoZ)+#~ZqE5X+CC?vOWJY=nzB|>KI(u|QINEILVx#sS1AEtwB4xhTYQl4 z1m_AT^>)=28s`D%HTWSzy9R~qvHp5*EnJ@nXHVGi96f=lPmW{-Yis$N^;cHh`E^EvAveHpL`m{fy_efsHi_herRfZ#|uuYJO`p?I;-|Fo0yT* z76YH-v<<$;0TKm~y?eZ#t$IY)_}dg1QqF1y6%;ZQ5?HleHt;0vA~S0DG(4HpbjP0; zCDQet_j~uhsCxZ<<*OU{sSFYk#5;jGIYTg%iWeH)V20_Rb?-v*=H$$?vsLK?P9*@g zf0|%{lGz-ov{^j#=VVVo_~eJ$*0OTmY!T&D>a;l&o!LAF`aU9+Fl~`;asglpD2Byv zv8#2uat${#A>KIe3u(DQAr;wDTf%$*H9zqMslo=ScqaQY1h7z<)x#DrfEx=8oa6xj zzd%BcO|}R>30UR1I9`5MiLN~Yh$^PMV~XqW%0tT88|(TBQPq!Opq68bsfeK5F<=t6 z>zE67Ot#il8NKm-y1Bm4Z~|?|)M9sW?jh_8LTc2tY1+)z>>6s7RZ<>;TM_gb>jOLq z(gTAhS;U|`j77CXdIv;s?9a2~KFJ_w&1!#EsluSP!-+158LqN@3VQs>rZUlBlEtf$ zwV7yMwT=F0XmvIFK%%CK-ThOBLN81SSrbZw|$*of>A*3$GS^%tV+h zR56GY{xc*0k1}X7P+`?>RV%gRZ#$y$HSIQnSvB++fb7r~SIhn1B?W?3wNKOv)J*?_ zn!dIA1&2-M3l%s@o-~e z^v!vJFzDvUXs%X?$?vA1;wAz%8GtC5;^MyBiNS8gyrIS@X*PyC`YGwXG|M)qYCkK` zul?q{Hq7gDcEl|BUsKeF@JXiud58TW4Zv~HGufVPWQ;Zh-9|C{4Dmm1$NNtWz%5vB8(k0gWssR}w5P^&r1_%C} zzzL8>(!nmrpw_Jcy7{^77n>_)PaydNQ2kqY$5r=%=S54~u#O$Q1B)3Z;1qv>lx)g4`@~fnJ7%R>knS#taV~o~@Wi%ue;68L=JLM@KL#K;1jpk0 zJk(5`_X2QZK41{5!mgWi6X(gZnBn_z_7u29HY-y>N|&KrimC!B=kdP87yJo?JV5_4 z)5RykrCx7MdAM-ix|if zy!odbKyCtrJ_e;9<2cMC?8e{iNjl}u=}axTyZ79wc!^xuq|z^)8@Zk(ujjgaQP z%uV03R~QJW3CNCw@7F20{aaxG>_dvI7Y0lX#pruHr4h8%y;j{N{tw}fr~-R=e%_kf z5<&#Q6z;C5DV4upKetQVxLRUzM*C5t;9t@V-gf}QC&9XGNL?xyE%i^sP{<^3y*Oif ztM)8lxP6>*nUJRuux2i(`TH#QFsKO=NS58P$3VbA*?Jzpiz6SAh?PdWBzXU0fFOB+ zF=)#iQ{snVXf{_YED*ZI0AO<#jg(>Bm1Lyg7EFa0>Q${;?lf=o|B%h?py#EMkIL}! zhX!!SvjAFKRK8v{1~*U7N-cGzENyqWU-sV-`rCHf6hKL$fv$0kp`8V(wr=u(vs-qwjm}T=ge}%>?A7a^+zM|JpmF2{1+h5u>J| z(O_jep85Q?`|(0dj?Rs8Oo14wROsiznwfCTVN9dZiT%GP2FPi&l3XXbG#bE6RKGi~ z-CN7A&c~p5Urpk)W6*Ykr40Zne+v}tiYw>`CrEfP3W<3X%yHMg_G_H(x*KpFH_mg) zVzUGUO|K^TCZ+)ZV>!-G4`Ybwh1=ec-u|QJ_6!5~1Q!y5y>G1al)UHxdS|ZniYMZS zP5c3ZjZ0o}9fRkGAu_)-%>Rl^Gw(_4FSpUD0%V08b>b`VAGlr_+&DtJWIaaLv5VgU zEM7avoil;d{uQho?S{WhkGrRU-4!Po1JLa3`av0`;I`o2KT*I2<9>Qf`i|@Yz(Ov7 z5Id@*TUho0fMEZqHz{9zON|VJm$I&ya=Bt|g2?rGnM0w`zfBc^>7O?E#kAh~5;7i+ zF`a3^KYjCWbS2a&$!CDQ#ZVjiK^S;r6C(8wVU7WdLR{BgM|@(opZ_S`4&u~xQ*0gU zBB{4lFJ57WMq9%Fv!s@G5|seTqnZl0Ci`bAe}ZMjbU-M=CJ|MtJ6UDx>&5|$JtFu& zY2uWsNN(ubua6cLkD0|a+mkT=*YI_Qji!;Mj&;k*m-*2tdi& z03dT-RxPKzGXY40@ZUF;O#Vq~5_nYh6}HS;_)x%VEe1gMHVpmMuI|_j-^%#;zc~{S z(hIBJ^f@x37IiCh0wMVqDWi#hYjp>71xLP8YbVaSLU{seVL1nOY0y8QJm_owB=iO& zSi^7(Ny%Zm5wF#e>3>bi!@#^BX!DTg+`l4xrKbU)Q;LnH_V~Ew)kaCyh600BH3`^- z{TYf3D}Ys67SibQ4>&LL4Op#PIiC`~09GL;?(MH}cXS8%2FR7wc$3vIq+!4zIOP-> zZ{LKUQW5!2;lT?21ujxlPXxcvo$ZZ^nRfBh_1i$K-)j#BzotAVznU-<2B0>=eZD{~ zbI&yzQaVGfb_5pG9piog1au-4K$6PVf#=g-Y%1#xApd`=$o_S2N7>fwJgEZdu=&i? zun7HH561ri^i47N=G*sj9Hb3b2ebOitTeO$eay5e5Py?AG~!A($?yD+=JgG;r>b4I z{G(0H{;d=s;{m0f2+rc6_zW0BboLF9VxWL+0OCsZpV`5J&b7Rc>`Rr#K#t~rk}1Oe zb$18V0}QJFiJbaESOc(hcQLi{Qp=%do3)4ft^`k>UL6Gl;P7L9^Dg$EfSF~$L5$=y z3;RDLOt3YpaBP8MRQ+vdd&&4A?rqjT9UuqrWhHXjYgPY;MhjfTcq|TBlMoMJ$Y5sH z3{E-n)E3@uKm>VUAVrP=ioy zYhB9&yqV_AqR?INyIX@42jk>C1gjZnY<%yZj6yI|2zei*3*r;|+Skks;O+>FhQ^aQ z{^Kz#0k#r~$C7;xwE6MdReKDapiRrcRef``crwTQDh5+W$1q%|iznTe1x?VdO9?k#gSkwa6KUD)rhl}J zd(Zemb~c8Asx4r|hZ6>^iZXui9)*1U>B&b@W=vmfYD7mR!GAnY#6LyN59tAU%vZ(& zN*KfN(LSH+$$yk?R?K2ij~I_ULn`Icb$|x@n4YF-1ktYUQ1oAq;_`n6p+AeC6#hVs zo*Wn52G;3TDsdU()tL*pViXv^4$Bfh=MI7y5-!CCP8X%;)2n#^?Cx=3O^vq19Ox?b z7v=b{)i`ck@WujCKrw0&T2}`P{*1@q9|>5xR1AuKlRQQU4Z}ofYK$OO9KuVJOpXTk zo6NQW5zJ~pKRbwaG_5~onuC6}*$OVW=w6I+X5EVYh(caB69C498ho8MS@jh;8bz@) z=A-~LEnajd0Yv9B-&5z|Q6RQb3ei7~x%zARVC1OobM%oCf0 zLf)g0;vr?m5LII(Wp(M%sJDLXzzLB8yOdmB<0B3}jJ2u#SqBn~i2ImIL-N1Q39J~m z&3*16Xg;U(hH@8lVGzPN8ChB<-{G)lf+j?t!2B~qt%fi%-)Bw6f~#UU>a6Qpf&fEt z^_~VLBkbS=fK%m`}c-*ZAMK;wQiwVJM#6EZJ4?E52$FSWCYijcoe+t5Af4Uf!O+-D6~GHl5geami{AhIlZFz*Q4TL@?RkKPlB8# z*I>{f3mz^2)=>dMi2ssS@D7#i^k`U#8cd?SQr$Zb1Zf3Of`8F%c2lBr>i=HeKmn8? zliMDh!-QZ)gpS4wc*p3qa3>bt4jY@mHw|P{a>r}|8AAa z=-_gRXNhug0#b|J3AgJnghwt;K~=#NP#UAu)L(LT#Z`Q89)8JTGj6?VJoHI59T2yl z0yw|)m`z1kf9CVKh+(zWCO{wil83F=qo(ox)A@l&<)B)z7aZ6zgbZmnXe`NaTGDgD z2H>xs<&wnZ>n?rL?_2bu)HT0mSyvj-j+sPunXKhajLkWp-?efL^~pa!+f}4YmU=g2 zlKNgCOONnC6k}B_vSrM2n^CiQ75b5&44P*$vp+JUG_09w`fJSyCl;JFywyK_tU2(Y z!3@)CH@){7Yn&^mJ!sn-@mymf6;_#uXazb&>eu)}Y4{%kmXqPTs+Dw|w%wlk-OF`7 zS1$^w5%G&XmB-J)tK)9hl0Ewtc>RkLi}H{XIh?sx76rTGB#F&jD?>*9_<6`1WujUf zC}SWcN)_gf<3&+DBT8n@TkUGsQEP8^(0v?`Q10m3gKuu0D0DYDoQ~3k0Yi#Aqc-c* zeB9T^1>19fxX{XvMCe8g`=MTohS3ShJPJNY1wW?uwWIznu%YH!npFnUIXFBXm1Oyx zv%)UWApi1=O+A-L{>gI1K?pVtVmm~y!poi+3Qdy@?)$_R#F#~XhEn^Xem6BQUz|Gj?yrq32Q%b9<8x8V z?fO!d()>;%M_wzRj!H0e+Q*Z?ceAk2%jsxa3(AdP?gvcI2L%vx!}iozfhEe%Vso-$ zc}N3KxG>YO@fJC?)_agOBR<)zwqxPv4+0z&tIf@*c(1ph+?4S{?CW?k1g-R`#%?PQ z0+~5l+Nyp;ipbPE#sBVgOe?FdUwx1Yx#M&6dop^i_vmlO?UiP#(+3+P^mVUL*wDHi zE{iR&GQvoubU7Gbf}#XJRns#$(FTD_$Ct-{rBdD+R9KHGE_7(zFEveT?~1GM%~sp` zdxTFf(t@CzK;I#P!(N{P@es}=oLgpML*FbZr#)ji0Xyr?yKIeuE|kA%l%U;b>qn4b z>?H58g z29qk9f>T?1y!%M|H8}%d?{_4^q~u-=30SHoM$>d=@Aq zpKi7DVW2RwH%EhsSe1ZS@MXXMVvhmoc5=oa_u_VbRR}~gO!Po=I`>w3vL{$TSd7el zq+ygaHx&9c+Y$WpY@$?N9viv|LC??M@n?xd9_2LI4TXRfOC-YXys`E@fP48!>RoiQ zaq}~!a_d8Fq9vO)d;7T-XUf9uo623u-3cP{c*N^zo?BfCrtML4Ovi10$=8c-A#b8A zSMe#;_!-_Y>xJC(6|EP39Q_-22bAhNgb6@$LLz)TPvGINb0EPeK6B3F$;-1{k_2(@ z2%H?@pV(~C?9>YI@;8|?iPxuhW*fabd4G!r1udA@uyjvnakF_+scx?hZH(+8N@2R@`mJn%VV9?H{s zyGAdAv#H~GaObr76aIHLjtxS_lM9fg(i;A3?|7+(|6JFQ$68+323uV+k`(xIK=yM zG5FSCbqG9gmgcB?_BeVS?7nF7xYOWaKR-_t*0ndvX}tcMj0EB- zzjFfbD;CuBCk^N8Z4|D&p0VosqS!zGi|+v{c{-SPGfu)6*XU!^XSZmM_xh04>_MSk3M<-rEf6Haivds_su;Y4(lNxyTza@L> z(8z_$Z_yi=MscdDDrAsgN{xB~Kcf0kIx4r#c$3$JoP^Vig5;~mKCjcNDnY%?a&O;Y zz8(`G8j)e^x{yWHC6A7dFblXuWsaX@L(sF=?U=El2_h8>al(ur1!v;r6NGE;WXFCt z9tfEYWK(B%d4q>dk46>^Wb`aGq@Q2|3p|Sj9mjfbQ`9Y_l~bwp4KHe#*YqzB)^C#O z`-TnNG(5&N%Znouc@?@4XtvMRz>?`jF2>}~__-2#AV`P>ze~MSSN{4?Y6VmvjAs0K zU?|kx<*B!186s1Gb2VUdkjY{}i0R+!!K>^x5)rmi~}N5m>TFP+oQ zQP&%<_a!+$S(E3?sB)Z18_r7|VyszzM@M(B#3%q4<}aH`gWzueL59N0H_;5}iKph` z%e!IDNQ|ranfv`3kz~Oggps-eJT#s^=0UzE}t~_)c1YI z=m_rDc-0XB>;#2aZu>*82CEN|j|x^J{G?bFI%b!)LKD4;A$RZIEp7;&5ut5TtbWym zi;z_Mc#CA$?`=w0jt~chLjOA|-zbtsW1oi+T=aHwRtsYePN}7weB$ z=o@%e!9RaGLb@uEi3={e{78F|%JpNrKwhL$otI?QSZBiNb2CWP;vQ^@eeL3W$`7PQgPAezWKO@)*cmt8D7_iM zPW!iv!9kB7Kb8Xh_hL?-z2?o$VpYwQ(MtteNTyk^⁡MoKt^xhqr;ft@V)_Tp3=EDjMIB?M>z@d9?!%$HF0g6YT3tAk7X zyGuRA1DEh0xOK{D+2l9%!{P>BIM%yvDY$dx;)q|kU6Ya;ind`zB58^=t$Jr!>X1!~ zL#s|WFGvxHxL5IKFZ%9u{^=8R+s^4cF(eF$7k08}gg`3q!(|~FetwlC8k)3NZ~Wd# zK5P=Gs(Qj8@EcxgaW9^g=*D)+SL%D|+!N3T#a1K4w$Nlx>Db|H0Ld%vI~w`HrnCg-C(a9$G|WG2h;5BWa}8=e zf|6$OI|%mT>NPC*%oF@Jw?>QDM#ivKAuTyh>!VV^SWy=r0#0aMIDl(ac_#+~Nfi}^ zz>;@!)Hyiv5(=LLb~k#^c*vL8`*)AmOAIwOcQ6RI9?c)*yhJtl@GjVWgFviMShQ>2 zr!J0;tvwPm$TS9OtpxG&`{dGzb@*RwS>mb zDmEbZIPgMu)yHd0L7VoYUJi+*_+N^|Xy4s1MW#EW1Wfhx0vI5tw{0xsM${y*}G0Hu`ir*qCOMcjs(( z%4CWV?pL8%qmgyJxz(@rgOC8>*N4D!;(Qv+iu4Q8&eJAUzESsmqci^a9v@fur(L5* z3=9$@h%J*JufwyX!8~HWR?-Sn5f7 zA}VuGTU%W@k>?K1M*? z@0`z~*WMo|3F-SwveSI&N_D^g&)~YET(S3pCaBqMag5#z+D_(lCd|tB{hXAOzOE~S zbNp!OP1U;Bke{D?S`Gy5gu?pMjE^g@&piSfRDB8fS@e0V&~N;W;jl5sT<&_>5Q@_3wt`;ySrDpQ3ifn+&w#w<=?W4`j^ZM=c?u>lkB z-%XzeQ>1jXsBoIU?N{v1}!;QPxOv=!mmr zpM^+?*O9_C;mZ4U5(^DX6~_`7)dPF2ftf@R0?GZf^?mJ;;4X2tOCA_#75ZPHI7$c*E{(N!{(-}d;dpOm^GW&`|r~h{Kz5QyU*r7 zW^iU-Mi9VN^Rxr(Jn}ZVH1e_D<@&yLZElu-^+w8%7^dLyJ81at!H?h`nlqNZ-$Y2P z0Z*7s@h$#)5dVjbAy|P@+Gr?A+iXbwH2xfLZv2`by{B2=4vb_o&?f{{Ps&H0r+aq7lw0Gtd-{|9k47@~#1A;QXtSU) z4&uXdTQQ{Fj@nY&TWkGZut#umA&^5qEM2U{9^W2AhY$%o=SchpEb)VUi=pg=el9h6 zy=6rd7U?+%`?(_@23l7#XU=n>u$cH{uyOn;Ai4OcH;W_;`jRL^^iSd2=6(&W3dG>QJto1V;I;8V2+tcVt6x|!al-G4BNbw=hjEvw827dCN|clc)Ox=A z?zHh*U*x1Sel)hdtn!>SW0$Mts_Xxs9@8UG`m2iKW6#?3PG&BBdOBxlcX)piJ4!!Z z?}-zXyP#+oVfMYC`|I@7#Z43S*zB{t_~+;IP7>=H_sUjhXFkx)S!UvawPt+l?x=Gz=2R~Rr}`ZxrdO^Ej6`QMKQH?_ zd*6@-9JP=gHXUC6X{P1OcsX7g9a?M`b{mVHB!~UCY{4qg(dg|=dicuelW+ysfrnzf z9&0X8=*EPE{H>NSaLehMik0_*EyY`_B!=O#G!j0n&dq9ptCk=R94fESz=>~Dl_6R^k6Zze*aFJGD; zeHqs?Bkv4r@Hg)CvhjuTHu9E|2G^*>LZzhITJdj zdDgc`Kg+lh-cA*%-s)TectbszxCW8}@mbjUR-=Rlf^tDKUIeHhmY3}B7p!fx7ve{T z3Xf1y6o_qpi&(eBT%RF7l>Jj~5TbtzVpE!xWFa))c|MOtg~NX%dCaD5wS0Nmj5i>Z z1`ib~g~)z6aBqh-x8XxH`L*u0wul;{ds}!Ui2~n*h)Smk?x76-&Oi7f>KX5kvZ~P=>l+k$R1GKb)%|Wt&Y~P|8Zg#1cedTQ&M;I4v4JCAql}Qg?Px`d zU0;l7pddH|qQQa!h}s>$n(QG`fpaWRqp2WGHG4KP9$2TJUjcAqejeljHzvIp2$CP^ zH+%a5L{YADn+uA>GQ@((`acYEXs-_;z1>nm?)k%HD$aij3&Jlbcw|V?idqQgL!pS| z;3@^63+>wvYa)EW6Ozsc!+m5zTjQ&tutd4Ecnjp{PXN}2r`9tX&wXG0W*a9mMaI*Z z(XRnrm<=7u8TqW9-`U0-Ai&Q3TOIj_uwlukHLQ!*-U=kxUjB+8;Zapi-d8Wgg37Q> zq)0tEqyD^mdjeazB|A`)P$OS=Q66lt*Oiq<>^KYLLUfLM%RGM7#Oq_bcf)CK^Y+gF z+GQ{+d-V{PdoP1-9(%MBB%Wrk|1)GTgxkr=cXshXV)xs{akE%(TRs11_Sn{9<^6dz zKBV6h1E;VfqG|)+&8y&pQ-=oOR~b$(dxt&$r!j&P+nydl8rc!U#r#pm4$2yP8MTfJ zw;nG|6n2uqQ`pY8+_T-SPTeF#aGyF>VN>QVQrgfU5bZibCy61+&MS z3cg}uaxMxkdihe~Z~T7urV4g#_A9-?9e96;inUUXdQUNqITWA4tuht_1?s^n|HPqJ zE#5l0t?oE2y4?`aS!PJmfv?}xz@`eq*EaIrI&VkHqcz9i(?MLU7A&*X5j*y{a~3?I ztoPS2r_FCm%pvOPhox|yn$q4L2f%vWx}VH`eP_;`fwQ3+s1k1b{MghWf{&^;fgpw$ zj+$R(!CqZWd9L?U{6JIRNBAR1q}*7f7$ZXXNiYe_`l}|s71&<|v&EAMeHYis%SNbf zw_gcP0crLBDFncCG@|0}XR(Ztu%XXS6~9k*zTk#FHsWJ$i#IjwOCia0-wn-WGp5Ub zU}QUe_H@idJb{vaQAo;jrQbC6c!l)&rxDazo*@MGAp#LghXVOL?vZ!L5z>~86iB~U zGA!>ti+_lrP|MW}0^NfZ?4^``B{?W9m05vUx$&;nRnSI^&@8uRB%(Wts9|AQfik|Y zh@FH$ZzO3Yytv?~9>y&7FCZI!Bv#Tb>w8k)B|hicO;$$6epLURp2hu~VzA7L>tU3M zIc%O(D51+l5QWtaIt+WU-xxVOQ~O8_NE$CxEA4r9b9TS;p|FngKm_^b{@vI3+>5_r z3j7vCEdu%R&OExnM&Xlwb00a|7Kthcu`gC#4i?+$@81g+%^_u?*Tq)N9`tWC>0AmXGa@$`m)3O}RThwugr0wX zPtXK4>4?S_ZF&`etEP2xaZlyew{$48pBuC(59<)Ay`QVKGfCy}<24sOWkR-pwsskA zo8jouz8y@RUlDlY&%WNzzOvIWtXemjGz7b-cOATNA%A#Uc?8n8iz=w^L z?9Hd zKk9La;Gcz3fHH=`%I^+BASIjr_YK|o%{s|(+lS{f#CoYgw_KwiG>K?fk$$qy_3L46 zbo;$bsnR5$ngSIPxsATS2wssClXkx7+rkl=C=9tc-Zkew?>A6&XMOaIrZaj&e=MC6 znpNSCgK#?-%lMXc2|zxIzkbCK&Pe@S0zIE`^lDEMO!R_@^)$U+;}lQl>D+e|I`tdT z$Qze&UK5ztV(9Phc5oHLl6Zi_u3O2>xSSk&b{B8A#w8aMmVV2|grz`L;r8$Ovp_QJ zxu}m{=G+7+?L;sRo}e~sEE*e}ct>I)SLSL3=&%?Ps}F=l5#p!T31@6K_$_bkuTgb0 z_@xdeOHxqZrN*9(Q7yNIDXP37L$Dh*zoif;wi>pM-c2@?23lS5f!YFU@_Iet;K0vetAfIlr?@6u=+q|HCqixJw4{qN; ztlvio4HLyTkp(&S+4FBt3wY{#UPT-G(L&g<21WaglY3h;Q|q67QWixfFG~lmw13KR zxRwW^=v^}NzF>K=NwVBd+2$Ltj~puZ7~V#%*Ler`0*dJMspt{Z3dRhm4(M{jH=W8X zhZZtiTEm%&)(Jf;U*@-qYWmttxX9o_tfsz0!U;HA8ct+??vdL338{jd%}yjoNX#d9 zn~#9T-@wPY3jgSlSheBK)6MUe7T(*hg@ooR)L(c+qFW|RI=O`WC|W|@13dO=nB&gA zI?kI2a9Ds7(;w^&;1i_C-=5h4ot8Tl8mVu4u67w-a?>y`AW7R*j z!yu*389YXcXy|C{^ecS@VGwK@n>j+#}e;Ov=l$QN&&M--&3aLp?>9qY^W997Fk$qZLMA%_Id3;?7 z7bRpj?U1dSmHjBG%Bbzbbg4xjo$X8epVh}nhi@SN#Z>YNR4f|r>i%a+ z8|3YZg8-u77^=Ok;f@6@FCt4#dow=lKc1lo&F-WZk;n?uRAU zXQT{odg8si=uKcw_m0oJr9|Z`V=;CJ&by}wcC07iVWvAc!@}_%Kh`NS#`^jamSfF@ z5dwLvjb0Sb;N%o)!BS?^DJ~tnUN3M?R@kv9*(m`s54j~dZC%n+o2~ir3F!(sj?s>4 zsfFz(F}l3Ws)7gk(`}ryMZiiuF6nP-n4vHH#Qa`z`HPmCrc`-@y0iCRX?bv3!f=Eq zo+j3Sx>q2}p2d#`&(ZZ;Ki{TS@&=pwB?Gu|uoiuk;SMem#NiYzRx z?gRgo^(XG4zS8B&_mNw>xvsv^Ln4GP4+9C<2KP4juqpa`OH&F z6W;XdnZ1RD!foieIQOiiMZ4Wwc3F1gJHCattNZ`7E(mS}8e1}mfFcbWFLXG=_&#*- zV1?{OA@oN&!+e6?Q9svLkENgn+vAz05~%o{K4?9XXX*BRwSdQ$_7 zz#!6<4rsjV+4rGTP%vvV@`JR7(HjkG%=Cd?T4XcuraO~5Q;^6@P~5Ujxp`Axy@l5P zOlpKf#usn9kFfZA9b2tn)IKHq8kYJ@jeBIkoH~*i+yMFTE0=nI@?pIx&MDWT*#pn$XMnqF2 z_}F(rk{%>OAW5T3hjBm_uFle>DLDCiz_R~l3qWkcj8N*DclFrw~Z-&=!$ zLN%MnzKOsrs@kP%PtCCyza8(;|AiD*9o-|4o|szx8(wm+u+?XOi~l!*(o#g5!pG@&L&rlUZ% za1XEyL_N%4Yf>$56UFe{?(38AI)G81_`trWL(mgkkUU4xId@Roxg^2Dy1Y!JO8oJ^ zHgOwMc=6;?e-3t<{QR{ZkUxEfAVu6ZhEb@BHe<0ao=J%H?8<%aj#ud6Yvq#;&G4tb zoAp_#_x2kC=&kE}sAjr@jSR0GSC`LY31bPHS&0`zO96ZX0|R>d9CRLu`T4f06yz#j z7goXR2%_mdTw(}p7`q-~p>?4p5@#);K2BjPz20hkvu^~d)Qz-4p#x0bFJ4axO_Z+G zgp3&}t=%~6%tvgi%mO+OFNuU#0l*Xce%2}x|E2Zo$$Oeu@P3jwGDSLLkNt7ZC1Lhl zo!PSANpUM%Tmm8tz3om@#{1tT8fZQe)Nt?d@hxmLo5hI@^Uw=ubM$)`?N~zU9J4w8 zik+@@Bz3A($O&4NYBtjOJZbgHQJeq!1iXW3tl&NYS{2~kj)u-*P@jFgvK?8&?)b6A z0Fw02AmEI{xwaSw8g<4B*o?;lzDAt$$i}RODVZiM6jCqz9+iA{-sq~ZDGnfa8xcoo zp$Hi-s(vikDh4LZRo*847>S_fpnZac76u@{RjiO>8z}m7|HJZRXEqC4#-Y!0BZ85E9DGykcj$jUCju-K-9N;R04zEbR|}MgnS78 z4u7>)^b(5pNZug@1gf_HPxADJY&j_u9r3*Q>var_@5W$E7b*OxpKK5t?FP^!fA;gO z>|ju5{2g(?O~b&FKUtrb%OaH|1jQ+8!2o+LreoM>l-B4N3)B=~iU|!@%#CDDb9Co%!Vi|z?0w#U73ecYQ zCjsLaDXZ$J4)Q_KA*%{B0jPg6XfD15UU1d94&d7km~Or(mbqI{H1D61HEr;?mJ3fe z<6zKV8zPors7GKpniP$bp6l?a=_n3>#WVu<4-#rFqXaL{>0iR^nRzFsVjMm-b` zvAm^(!A@Qe5U_UyvV+af0VJax_{3vC>aQdeEu%SRfe(g!=C?ct+I@SiQ>+G}_P~gJ zv{WT{V5Cw_DhTWuV7Mwh>w0Ymkc_`yTQkyt0W5So>eQggfvvGw2PTY?!U7eh2X;C^iAC!BuZekA0Os!H zqKA2zah~We??@D~%3HdN)Zz{J;L+Z*OMv(6k1Lh}iUHmuoy?t4IeZK8M)u1&IUAT! zuI9sGCWMTP^mBRGGj{NSaWAfI7%Ca<=LOpDt^$mylHGS1ae%bNXQSmngCX$|-jgu2 zUwG}q1jHMC06F!TGd=)c3#D8GE5Y*rWADBHvF`i-@ffzJ33K@9q7=bzbM?;x(SH=kqb| zkNX`ymQqUZqRmkHqWsy z2P6T*Y><938g60T`D^|;h(kPB^Yh|`4^h5SyReGvD4Ap`lm!6Z7t;#K_xILqU1aEmkn7N!Ah4o|3^+g7I-#z5s)*vVPGDLjm5H=T%z#Ctwg`4C=OJ5gB;E2Mv zq2agtCmLX}#{#;H5Lj%W_z)}1g3h!I(JUgc*znkmvXg(Tmn>Qt*3Dy8wpg$GRK z=A4FcRC&j9fwDLQSak1{`&xjoW-xUEgtZtv!6V$8herWw{;St91O7UH>CCz^3j1S? zRag=C$Lw(Jn2|$M@e9LI*hp}R>bKwTy~Ln-EY@zP!yDz?C)Waq^(j_);iKns;0OM) z%tc}-ED34}Mv0OO;U{HNr#I19%$aoSkBE%jSuKW97{_!2QY{P)sf zBr6Hb$oVAGnnHPWyPaGCO(0xADAQz^6N~9B^`bzlFu*h9W={-Wz+&{6hG4AWIlSm; z5}rrPL@0Uh99L_&MUHBHDLV@LxU82MRSwGvHn0NkgQ?8g#fgw5A`t7WA>bNn8bwCO0hfrltz!ir>THY0pK0!kr)EGoHZdWY19cYQ z&gc2f2}Kpr?HG(SAkkM50JNy?88$fRPeTL4FmOqt=hC*`n96#+_?w__1UsSZA-@~^ zp~6c?_!4j6Et5K}HwwTk+BPu!LE`7nY6BYH?YIg|!XWT?hP6);F#LSIh8gBVkZ)64 zDQ_E#!CmFk2v&wuEHeGr8N5`MsGD5naTtKmcm2NZiugmeP7bf2WT6xc_=G#0dK9EA^3LhK}s3!N(YJnfj(0RFiM zgZ>(m{g8+WovrYazIoKl!Mc*+^?HYby6iQ*e9AO_EXMF=x-TXPz~~zt;N(132=g)M z7I;OE6=|#&+YW|%369f`!ckVDV`q_>wI~qO8}8ohF$xxjc~6IT)3;3E&ieyu@Q;Rb zb~RL@5fD4~mzM!8O%#mH@eik7RK#guUz|LpV%6l5cb^zS43nD@b3*;)NU#ATrBNUR zG6VxOm7-!V<_3de|Z z6-kRk_>?NIE#CceDAN}*Fqs_$nYjVS?2i2viGm5AGpe`(__5Ufj~O^&lO)s4xbUj< zPae{A`QMLl=G6mu1j7IM2o#+n;+5)As;HaZiZBrLXVI}|$ms;cEAyWrKd#>=5>UO* zxVH2j(KNh8UnTNmqj>nrzDhZ*$o2Cu@d<=F%#3G8&OE-jKVW^f`T4fs{ZfaJ+Cr5c?eIkzdAGEiIT5BUoT_m0b0=+bs145 z17K8H-I8ScGZMAG9%xG}jKoF4^jy~+b@eVQO(03k!cT0dxPr<3`VCLt`*n*1MeW0T z?Z3aH*8>#G%vLvhs3sdsOw8QAM@QBSWd#_RZb%0*|M8sqlQn-Q-H;*87sq;lj;I;b z^qTY9q^*E?T2<91f&UjA+*-4i*!y5fpC66Z{WB2;gIQ#?Fu6t};UYhe%yN{5DjX$S zbpH3yU~sQtD{~e*k6AHeF;BW@FDSrrGv@TtqyCJ8!A6@!Y-@<-4{Dg0$WND8Bn+pvghfMI9PR1tDp|tT3{@z;su084jyzn0s`s?5c@j)MK{Oh z-z@}cV8?qv1Ed9EqelcDzk!}c&w3j*7jPBe1h)RsFeYz~k-#5j2&u5ymDwq@W<2FN1&4|T;{f3pdYmLI`Kbe z8?AB;1;ewxDXFOfpjuI20cuNUg#!y}&%lY`TV;L6?Yg$IZl8Hl14o0;%&SwJ3RsFZ+X3`}YUoAyZ`7-@eb{!-+r!+<&hcISN63 z7XPYaZaMXg!$WgvrCqM& z&4t^|aEMGr0JQ(YoHp=$Fx=f`px5d!3Up|NU}Ri0Ej{}HHmP9NMGd@{g} zd6(g(BVZIY8l)z~wpeqYA>KZ@Co{?LSPxXQTVM}21OL!{fYTkJ_^*}sXNZt=$rS^+ zso9*2pobU!h7nJ?-3VIBQ?>5GIkC=zkK?AV&-ou42P5|=L=^>*NNffLzvkkJSndAQ zPtPzhuumP2qtuRhVFS zO7KBARV7#8qQ>aaHIY7%QBY!F5 z%Oj#cW3~RFuP`TW-5>orqw&_eKf4~|7+Vx%Cl zT}+otzBxsl2{mLG_g{|y#>(j1x1{n{Bn)2u*Q(c|an}vvo-AFn2R_57gkWiLKdIc8 zN|3B3ubh1EZVKvjbX(F)(jVV_TMJSpKkKohhVK-V}F;)_-8kuRz}-P!Z{a? z_|?| z72}neQvzhJ){Hn6ufMa<@Zgx&L_t8Gv<$o`Q9wd4XdZNt0+obh3%;lO7xkcpC;uMk zH40)8hAb^O5oK9|^nZ9w_DwSe1)yeOU{rsOD04L<#OWG359JO1ru@BEj}Z-=xd^!O z3NaXrf30FN{a@R~3z>y)hIKS?I5B(S!&`4M)LB^U^C!+SFX6J&1^Lby?1o5FG2o9Q^H}aiHlNmfTtSmY9BF&kge*Ycx#U zdTH$NGUze)mQqa4qv(i~kWA51wP2zQGwyg7nUbFZ5r%cg_ul`x+te{eLDPU3jEXZu z`%`bgzu!83C?gP3DO5`f>*sc=d>2@PyUY%GCH% z+M8s&F3Mu$Ex{Q0V^pV_?ULu-cHAad&+En43Rd@sK}65kM)RJ<*>8#0XmxUlG(eI# z@Ap!Z8TZe94v+mKb`#56zEKTP!{@IXaFALj+kmXg%;82@Vs*@?*PYtnO&<>dscMH~ zJ8)zem+x*_l@hvlOjCxd?dQc+f`2_KIukPj5v_>6`)Vk$zD<7DF=uZ-3|eb1>+dC; z+QBJVmGAAkZOfL~5!LVu*lkc(i8VEXjs@n}Hye-1L}y4syckCdz!v0f89@@N`& z(z83)MLACa)0J^A&%uhEQAGz~Dr3@>z9*qFZ;=zdy&`auoJexv911%rao+xJH%LF- z7YgC}+%wY^A?H;={`X5%E}%d}NapvV4{|;SwO;d@ou#?jy~hvyvFpIUEXkVKMDnIV z#ImztmqyR2*JP|Wr{2x*saJiBT-U+oLpx#56B6j2#;K-pa%FwW)q41u-spjTiqloJ z3TNZv0y#8IuzlG&3~)Utc_)*6MWfOB^)qw&!O45luu&+f9~^{Am}w$lI(3*p`4W`m zMR*?~MqPvX?8a%}oNe2N+;@ojYd~hnQ%pG3PN8T1Fb2VrE73eZO=HJ? z$FVrI+6%;kIer1xR^#;LgFEk1e|c-14Rmn|o?q`mDA_af5fSb}qVOg1H{vvQ&UEc8 z8We;My_RR`kdFm|brU+C8LlNna3{u0FXs%z?+%wz+l4HET) zWk^R##0puwW)*v90+u~&YM|JDyx$b4pT>cFLQrORBO!)X)~16k6hNi-@K7D?-nfwo z!f(ZQ?)n{^3Jd!4Cg??oqfYFoYB_ePD+5~tyIJ52<7*6hDf8kKHIguCZ3QM>plQFc z2dP<(7GRcq5rWGWIB6QLov1BPa;Lx@$S^~Eeg7U+HLMyjYJOhhe!y}V z{voRzRD)(bX^@5GQpQ#SGn6^C^u|cGBBIc^4D#p_PgvJOAmba-0c|X&!i;@&0~Z%SX)raT;Fyz1V$Y2UIBrYlF`Ed5s*= zbXVS-AgOhCt11dQMNUMql{V6%M1&f0ke`2%2rKdv;kGW68%+9@<+;i#S{HkPfg!sjdfIetn1@Ihgf9j7o!^fK#j`;3kuopLgq_yUA01erN$N!r1 zr&uqJxb9y);3FQW&wpCJnLvBdF|@gf=sPvxox=UcnBIyW!EYnEe!W`$wcBW1-QIe| zo-lyela;mBAsNd}dqi3p%Yg^A8KkFZjuM8U9I>xDu2V@0 zg8d~350S(z?^3@&Ol9N0^2TI$f@#`+-5Gy!!x!ebQLD;3(7bYbL;)Ms z9X4v^hxvxXMBtf>KxHC-k<5oUQ#(-cw!oAc2ALb;FGZ7R7B&hjr@6!QBtzL*arT>S#(uJ0|i*G1j^j0bN1bixm1f?mg`io$5pfAEq+-e?4{{tYkdlZ zh7aW%r{U^04!UmRtMm8XUt4n+*LEiCftp1NsMbtLYfKoatA}nv7}Ek=RI|^CVlLoW zsbMxS$c~zT7zK(wL1k}8K`B0`Uk74>+3=EntgYrHn?9k0nMQ{&T@i7?g{?0c;hV{A zldeMagO(i2ly~}H$L)@`llcaU^fhGOPUW?qyS+Vh|0Q>Twfdb0gUYhd`U-ox_iuQs zfvHy(kL~8)1i5;qRCAv9x=y`kpJa?(U?(B2?o#@1(qKdC=fp$ z{eW3Cdj3795_oX5=t0+uV)q>Uhoo0V&hKyvh3TiPJ|LY+^=A8L^idhx+rj*q8NqpE zGVeD16WPwYZ8lsR9h#27`lRW;yE!qsHE&;aTzw;QV0%rUmM6}=XRECH6xtVMuQMm_GNw$Aua66Baw1lNv0?_ z2;cfGOUinOo`^_TdZF*TK>S z|F}!rjZ~(hlkZwer1nGdq)C6c|A@@OT2?*6@AW&NyDJw|q_a553n0ndxr~LX(A|A-O|b zvtz{~YI0JpctV!ZAUMPEV#b*KP6xl;IR9I1ycCJEXR0fN%wgi0_Vrft{2TH5Nn>}` zi<=c$?McqPn{^Y<`~;OUOKV>HJ=K$Pb^Tnk8S$w1@ysL39Y_K1Qa{UXa28u#Bms8& zbo-I721J+*nQ{-zJovHEi2>K*ldCoNPWf!-b{M_V^NXWu_0uG#ZS~Evw~^Do2`qL- z88egiRrxc*YYCXs_%-iqzb(_9J5?2ZXNZ?6e;j5ND9i&|E&Rq4)Bcsu>6o+jJAJWc zB&fb16rFqH45|Ao@9yFl^o2}u7?TW7y|os+jy7y9&R`F^bo#e-N=5UBTXA~qcf=rm zpDl;F`i^IG2K%2>4U?W0ON&popR z5}x(Ryt*#Hi#wOiv+f#c5|$Hg-f(xOk)6!a+TDTNTj|0xx8j}YdFm3$EAlpg^PVU& zt{nZ3wFkO!;}Lj#k&-76!^g+_2fdVVm^9Fz5jdpFlPVTD@H0se=Knxj)A4aY>UF35 z@@~qTYQ0S^gLMvX)5q#(oAe#I<`GV6TVXdA)_~{v6@-Ywm!M^5@}iU!xx%;Zhz*sd z@8W(xUK3cHOMd0Q!TrkWDP|GhL{0CyHT9vU^Hxod4_of&%*Zpdz`SztgpcsN{Y3#PiGjootC+twMNq^!bBd?xA?X&H z^E^izq&Ygtnql(#4)M8aOBVbT<8GVhu6+@=jo_seO(Ry;<0T*lRL(=(D($q>KfJ49 z#C$HsK`qcGc?iOxgCh^2!*146@ek%xG&;1#ASfF5RvmxhQPL|Vc6En3(T#ZPwtM}= zC-yqdoCCWmcWDcgK!j9yN)^_~5Y_d1gox}!-0sd4Qv30CA)KXD4)}15T37&`hg9Zb z=bX*Hg+@)^GF5f=X*yOd4-49UVjE0<078qi8>cr|@npjVcq43o{F*QQ>?7f?$4Z#lRHU3`HIbeXFl>ftT7Ri98y1J$~%uMWpy$9CvwB= z_EQ6o<=lG^G*t+ZPy104;>B^g^I`6;@p0vmj5e!WS^7a&?GWwKcm5gM>kA8dOo0qN zAp1~_cYWtRv!Neioo0OUTR(A?Cv4E>UDlDcwuCsS6?m*&zni85G~Gc~MV@wb(mK4$ z*X*2zsQsOrRuXRO(`>$&k73g3Hy-Yu-C-(8e9cNW`3Oao-^PRJ1e2>>ExlBfnOdjv zyV;RmvgkSrxB@*B`nOENKqz5nL!;rJG-K+NuPWTSN+VyOB4r^&o6fb_m$;tyKq8sf zNYi`saP^Lr`5x^pNyBGY3$jSR7by?uD9rabWlvjscbr@+Udw8~d{%;{{VW@#{)O(m z!=CRp1Y{N~36=T$69$uZk5Z0)~fYvrSZJo9Rj7taZ@*1A5UGDrvfNW}dU#{_qJ z@W1pk*>*<1oVfLoo0V-^pbwD;(z~2i=xBDqS?4{z>iIXOb6*`AGfQ7$hn(dLLp*@h zZmwxIMC%?Sp8pIOr<2#}*pci7(fQ%N;(cn}#VU4({kBZGmBhC+p(S9oqb~e_-@4D~ zh^9FlnrpV2pP*xNULB2NoABKF7FWGCS)bwYBbCLznSVUxLu_uFqllh8@ei(iVpOEP z_#^{w)OC!!{wz~`yb4A>fyg+E*Q`Ys_l{3Cf%8u8wvs&;Q8<0i`qIw17rgdK4LqUq zefeo+)Peof<1++df1L{xu_YxDh{qe$c$Me~$#~t=BMyJsm$%6zhNHbYe@QhC|C>O( zYT^r`pX7ZVHE=z_B^3(wN6VdV2@ql-iR#-dA8>OG*w2n`5AFdWd+fR@WbSWn-{c2e zjO$R;-Wb~+ABe)M53SF*%IT|Y@!y@sjKK7!NYrnYvtKXO)<+ z87T!g7einYiD1r-qBh07e*R6Jln9QKO&;rMHvHuqu-Iwb8HLuaPjRGIz8E1Exe>u{ z)C{tg%pMnunnHV_Mp6T3AVjU|x5nrMqJ%8;LNu?|@i6pDQ|9;Vk$mgCCi>OP)^@Y$ z(cM0%g$L=Zg*%#OzUoe_OX_RFlRMA-0`Ja&4(2qy|WROpAp{EHJ8l2ACyEN z%Vw|hEuFB6%PhJsDI{mj*Z5BNEh`xjZFy1GvUJ#1)04H?UXCJpqavA!YBKDrr|16G z7`)1wDqo+yK6{OBQ2h(Nt_SnLvGy{ZY5oYhc3|wgfo3fKz_!EmF!i5!agnZMbZrnrT|6e8gbp@MnDHn}qO^B#{c6D1sK+5nJwrRmbhfW^xk2!uW}(%68@Z z$yQ&4+Pq7j);z;tmK7)21T=Xnem}CWJ*voexf1)YUrjmQp4ht;pGhkjMU)+Z!O$KG zVix5Q>O7k+UiBe_H`BP4_7y9cIcp0)(gQ8(3gjWb1a-K%B*N6_pq~T&$-qh6KYwfa zgmIV}DQXkRK?S;YJVBwtg{|uxO+$I>)sWGt=Epfn#|W{2z}oM$s!hh`*h*t{MBwQ& zN+F4Tx*m|hb+>&hYEykGbNsjpG)?`fNWQ>VC~V`evN>Ke)9p}5JQymwd>WqKiGJ@O zJHygZPWJ7N6!edeK4U8o+?%PrX-8SfRNr0fw(h#=^TBV==47O5jMUNNi$0cq7DYwvzP%4Om(zmd6 zq~B*N#y_vo+&?o#c{^Nja?Q0^$p(uVC-O&%8_I@*wZ6p#Kf_Fd7}|9RN*wQ-4%fOg zdii~@Sc+^`xl*XuNk&VL5F7q&pkkPah1NrZNQLV|UU2w#YdN>Dhq{ClvTn~v+C7%Or-6zLiw(klb@tzkM z5}m12&?meVfajsJ52EjcSp*2md9MR13l_BHG`c=7!XhB_({M-p1z3_8L+Eiifi;x zeSyKf`X6ccD$^+(2ZT)nLqn~?w`jwD&*<-^@*7`Uu-N#mWfo%+qBMv)n|J3v8mC=~ zcztO2iCRx1`ZVc#k+g)x)?x;;SAPL0+VYRXIC<0>o7;(qkWriD(0-w!^8Yepvl-J66%2w18B+x~d z(aem7k%IsLB8mXxT9>ioc9f#dan!q+eK`mr4`u+3ExnG~W zUo;nI3W(mb2J%T{b#6HB#nD1lS8?^ksgbBeHfLa1x96QR0{m~_?qriQ(sXOdMpuC#jmC8l2Y8|?S4xb`6$3z5a+8a+QuS~qC_5rrq{;~SPA=jqKuvXqr zi{3FGt~r1B`Y@hLT(&dM>{bG;It;p5Ug8aP(+@tbV~0hU!K>xPu}Km~i{AZHwrPva zFlq&<$6BN;Y3sNR&foaGRv^V<^>sj`@RlZ(s8NvguVpnrK|x)Jr|N-MqGfiN0~*FSnLEzT-2hw3g^C?vwRylZx)J zv3_0ry3(eJv_a*e{sk!a5umS@6LK)72Qr19UaoEg%3L zvXk>EOSm3u`?(Cra{XrZ9U;x@bKl9qV+{U4VIhnedIF5W1qCH_lZhcWIg7@(I#SuT z#kFHL3GbT4JI?)+Df`vfZJx5;<}S7xpeJlY)mLQnLhVeCQ)z#7ROc+V4f)=UUsW$L z)iZ%Gz^P7SJ@%yVz_pP0qIbMG09-8zU%RfN@5j0Iy1RE(#{{UE*RE?WOgX*Wde_EM zw;-LcqCq=3wRDJT4&pGP&UQZcNQ;vM^huwfSQDk4RF|M|==^8({!aZ!d0Of(Dsx>j zL|cld@{k_P=oz6XBMiHU&@Wu6wDMlWOGJntidm=5Q2x z#HF`5MMx$>I!jSk9OphMfYMxXldWLZfx;8;y!xLT`v+6G?C}Udd8sLEWfa6h7yLa& zGMNttw>grO&}}Py6?wDBY29(;A=N5A+|pflyd~PKFp2FK1!^Usr03mr`a8#|*l(*Q z$Inf%rCy@MV&3-6QXUlJzaL{1cSxopgy==S=9lB68#3RmxO%Z(+M4H{4cKHjvZP!i z6z&t$?D&1B#9Fidt++%F#POJ*Xy_-8s)_XHRENg!RVW-!(M9>Wctn>&*_9w^Ecq`S*v& z-@>PF%d_hXYsHOwawb9$MX_mpp}XU3D)+sQ$&R;~Y;YZ=oD`%0yV$&k5B?4i!1H$! z`Z=%|hk~=c0tmF2w(Mw983oBeEk6a3&=D*T;qzPoIHoie{QZtR1iBoqkVf~OTJ8JL z+RWzIsk(O9mEZBa85(!AyyR(-ZmH9nz@b4CbSa|z-AYyu=D9=;V_FCZiL%am`fV?uI*KT>|jpXrWT+=3c9 zxA~4*79e*mJIo6e(KHzkdjE6jUpX4>1gInEER;JD@Cdh>1@*)pWZnkBtT?C&h2?V&3l1?2E0@@L&v0LiH3bGnZPi@B10)Fuo-GCgYWU${H3r+RK)@PTf!!|#~* z&_8?OjB+J00R^E@#p^H7nYV^UjzL1+4^`+_RvSZ;#eT_z8-X{Tz9j|B=9Sq+8d#kw zujnV)?P4SVG(S78eDF=5YmGpASyb*Bb;d&r46u|0n2s!TXK4S(2a@N22tdG|(+4Ou zV7Qk$eL&ac@Jq^L4b&tnr<^ay>Q(`CM7A~^n3VS)>L_J%tW-YnCNZ4ksYH`2$m0;do;VB2EsVF0Lj=*sp9uhF0N|;Soo8%kB^Ovw z!Wm#Zp(;|$R0;@*D&U{~Q$u=w4R{Rv*BIQ8L$ZDeoFOxj!*u5b9foyUShL#yuQiiJ z729g|6g@O@6RUy>|ARthbUXUKG@{CY>{NLQM6OmN<aej%O@2R+qmwkI7G(Fpyz z{}dt7itH{pXqh2S8x$nEOPCR`>Ri0_O4$rAN&_dhO=eg8Iw-K3Vw#BZ~Wt zDlzi9?6voMLdMXRXq=(95bIj_Uv zzy5_cH88Ya!6M=cCxAa}qZ>f3#<%#aA;KRO08ARU{vafRFZyeSBa z@qL3!JbVwZ}XQxWYGi~}8ro@1yqnPZIIlIHO^r3$t873yH zNeGdWkwtg97KOU`f#?HSbDYd@5gJq2s)48g%q3rr!5HwI-VH*JfF6sNr~BnVnyUeo zA-AM0e6Sa8zkljD1O?*+CjUc-@Umv?iO-}PA#LGpvle0e4V~y@zQ5O_ zym&Xea#teDa|R--E_KcPa|8~janNe&;#xjTK=cWKNsHw#lhLR~+Ks6Xan(M=z;^nA zgRS!{G1B?guKk;goxvFV-uQ(Sssu{7bGUR%9CBp6y!QYCP{%zEfY@VaFz8pf)_17@ z;l&g(ei>NL9z;V>f48C zqjTg$5n%*1 z9@c7;zl~Y!29ciY)Yr+YAUC; zi?TvuG0DmYfIc=_(&Qfq@ff1)Ct8JTo*gOlL_#vFih)N>zU&)Cfp*p6Z>CTGVa7K| z2Uva5@eT0$H3kNdq>fTM%)A3o!k{&T=KJSh-)>=Qi1l24(leusTYu$2jL?tK3t!5B zHapcYxi+K(3xJ9Jv~Zg9Ii`e2=!U8pCn$X!6?p>J$Rj;ZL0aT!*ia1LX1^x=J`HciLTF@GATu#yF=&zS&+Op&s;S8f;?+}n~iJJurD)bbeX)~i68fl1R z%g*%|7Lp23kY0L4u61y3yy#W3cagTM4E#BT`qtW<)!q)GGg5}uO2z3v-cQeC{&BX+Wnx@>LKr(~sA)(z2TnpI` z*y0TjY9s)ForB3Fj0JFSj{#;B10jM`rj+_0m@Cd@Li zhfuv>a(hpIf>F6E_Nbap2A%7bQt;Z1!^=5 zeuRO{qE3JA3YbbOECSV%1O=G2Hh`zplsL`x2_HsjXqklmxgYj2d^U>E`%)lEmsp8( z^$FfoV-fH{0d(*+fJqZ4LqUH2PcXA_ei8J^d}S+<_$jRjWJvlwD}CjwT~^z4-G%IZZ{4poJ7i0|ClKtz)*@1YRgh+vAL{7r~F62AKVGvalAIF3Tli z;MKEMq?*K8gTS%jPoPh|5Ad(!+ltN?55T4EGz5V++(vzmTaztzD6;uR_n9XIR`W%X zqvRM6hgS>x>nLHK3oL$YB_MvQ1x5C@hxe*UK!UcOgm7nTmGZCm#Fh{_T=SwN&~sny zvU4PEVIs%L#7H<(hc6@E_4#%1(HzM625&`yXph1&Gj#q^g-sw$cXUgpw zL1_RiAD$_P!o#!CN&pQW=b6}ddl(Z+NKK9yk|ElBet}(c)`OBl0H5(FdW%%X52o!P zB7@^cfnd)i$sDNe0(5SU0B5UT1gLA+M6^SoaDX>;UXAF;t{!Jv==?D)AH|FIm0NmfwAk>(Bp>4un^; zn04%f00~wKkbTT22RuNF&XBnPaF2^41rNvS4xY+_b_24DnuSJ?{S~zKc*($Bz0gP{ zI>2{usX63Qd(4<^@CpB*5)^}}kGSd*(veJVY3h=ec4=rI7&m&}#V=f0Plx?0cDK@O z761;-?02Z&rcn1Yb2}oVgp-B~t*VX*A_wpsYWT^r@?)Cc5UW1T|7t=s&qG}D$qPB= zccS*~)j|a^CBxH}8Niq^28n=B;NLe-f-@H;Vu+mcS68u-f@XfC^{(?TIM5EGUK|{W z=pyS8rbCA~l@OXZ*pt`jBV;^1Hl##>PdEDncnUH(FEHWHnq2n5f-0r3=5x}Y5M{hG zY9nw&@=04tDhd>!RI7cw#12y^v_JP!PKiLgz@R|F<=3SPP{itmJP_=B`u93TaGg<& zbpQS97{DOGnt>}!#Tf1Y{Qu`7#ubDUkNo+}i}IL4U3WyWFAlunsdNoEi*xJvAc=X# zgQy*|0|$6|qV5B7a%#z&BlFuOU2Rq!3;eAP(L>14Ny+j^f$Q6+&(kT+hY-m8`B1of z%9&njSd@|C%0m6U`@8PDLTy&pR*_5mM%C;0eHPMt82OuP5&0c2j4FH#`G+}3&hknJ zTAe~#CN6mk`Vt(4r#fLGTJ0R)%9kT8Z8E!-;M(x3LX6;`z(74`jA%jnzy}kiorDkr zLU?^%BVZNGH?|s{o5KD+H+*jNFSvvM!eu5ej3sC3lzpuhdqEGe}f ziXoP}>lM5$P^`MIao6y(noWUT|I~oX9PUVYPF&fqy3%F`UB0E^ov4PWnHyk8{?)J_B*}-ceo&VXx)R)mB#q?9uG*vdEWWzIAf)olYzEZo zhCtJ@YCOx6^}e{n-HO)LknC!hJT>I`w0J|i%XZKeCi;fCt@5?=?=?aYbEJS8O5Ncn zBsbb~yC!1Yc5QBo{VXC`@PueZaCv;EXJjrL+zJtD)8;WLIG_U-j*W|mKS4u8R6G?7XbP^Zp}DZ(h_-p_W_F!vB*$G&Z<77Gd>f7Ef93wE?7gRWu-eLIB2dqtgAxG zvUnHh3dUOFEfG(JP;I0VVqs{b1(Mg+-w@7@zvN{gshO(agHXeYYW}^lrU2xPVN;zJ zK1@v^Yu~*oTlw(%d$+W0beVq5mwtod+9Ucn6c&n=?Qh-Ju8Cbh!luQqBpJBt+#2AY z-_A^ztq;eAE=w_lDZuEhP>?l}*EE2~YhV}^hL=)7hPJz~mG0|OhFj}wG;1q>XE*UX zvKI`RnJ=3^#A)nhZ;!F93#<<;KQ7Wc7H6LMRPki-aKX%oPq5HTW`BHo%i;x^WU9-a zM?`uP-HVipc^{B*z5WVi;tO++qd;BpAA~;RW-~Q>Oad8jq)Viz%xBV@Ka9Q=E`*Jz zWij;2+w^BzwI!FXyI3RP#tzj)+ulHWzDCp519Vl~8Jeqh6RM7|eD`4aX6EA`A=%My&6Q67AWPl&(e7)^{E^Jz8x zOdoBWsWDbOZ-+Zd9>|f7t7ttD18quB6Z<={bdT*)8cN`F16#6rbN?aM%4#ihYJA!ZMio>j>mEUS6Ob-KQ z92K$)89)rX+1|#0@1=&ev#si zkNB!abP}%L6dA~l2BF*1jzk~dH^`jVli$BS8Zd{<4*vs>9SM3=m3l)NpRwnhv`k;WW0V&!;}%6*)u@467`v7R|;(Yb>y_~No7vU@WYO& zfQZ&9P<_=$%~|5oYG*}1TSOFMV>!W#&Q(znLHV#1bJ-JRG zW4n^@mTyyM(9M`#1AVrKwK;$%`o!PUZQ|5Qu!C#681;NHOnY9i-?smp{V%n)VfS@z z`=!Qu;=*OOVG^oHu9ca>=o9?kd+k(hY8Ih3e!LKuPV|71mF{;8fP318>{2fYWK|eGq!Tkt!>Ad zjP+>uwG(g4?#Wkh&?HaJCq0C7h)n1MT@uofL?Z&3)hKM)`n;8%i^-cgrkKxrE8iYi z{+?tJTavf0CRZdeiYb)<@OTyJv*Pe7;}`lQW;pF0 z{MKlyHqW%ilg$?j7bQ1F7DsYl=-Ov4wacxO7*^(7_hk^%q-Ge0)~NUWhF294@Ls&b z_0>P6T&)?NiU#)yPL5WOG-_x&=CiYWoQ*Kry$=MEVu#5FT$$zogcR_}S?By2n)c^Z zn<=f1p-^8V^~~bvo@94dV5ba{d?X|BOkTIYeLLDZ*SB~q=vf#=3beRi<4@6CeYBwJ zj0~lM9hex3w?*s#Ihawf0{|yihp~AHU$cMQ)&}|{w`aKwd0_@x>e6<_!$7vh=m$eh z4s#{{bR4;{Q-l}M>F6DRd9z;YJmm+44aOuW+j!HI1$`$mE)NvsN_OXj8_lVOzXFefBFT2hM{ea^#XCofa& zxb8a(xP9lSik-O#rk8mAikEPs*tMnScs*vLXEkP-uefMbaCXpQ@lCU6@LU*@=#Ae2 z%_J=#t*DAQ#qe~&N~q3rSFk!(%$ZV{;ZQ_T3lLCc@3*FEtkAbNBIC0LWoNZFpe$(R85 ze4h?u61KPNTwu|gt3LkjjwDb>$%}rw3JI4F*xwWFy-3ea1!1TfzlTvN_`sQAJnSFGP`(h$;ye&vVB@L_xS&IF2~0pRu(C*6(CQ6l)}WuK5Fwfvv@Wr@ zwf>dlh?JWXwIcs{mg0@?X_lRug^s{RkPrxoFCefRH0p9SPVgDv$^zoG@`ib>+6Aam zWPZLe@@W!IUD(B4=b#q*VP0l)-3g7>9@M%I-ME~Ern`npHgX%3R7B8bJo@yCRP)}> zF!|aNb>K%kd4di&>-2im?8ibzHr~-V?H`sBI*k0X?0K&*Z}B6qukYIOj%9NZ z?TKyv$ZOUz)BU0Me*7N{T3Au+@w+-L z9lQ)QeNA)sm32hTS_`i$8))7@+jYqAQDo-tX|LJNHawGd zPu+2#+OEWsKAnlNZ@n-+9-T$5Km4%1#uB}h!$9J4R zOdt3OxWxId=PNE)FW0%(~IXvk*iAeecs4}r}Vsg2E+`1Q=0*444cy(ZyQnq7`V^XV%h{VV*S?f7Uz zFY{cCL>6n1NV~Cn)W=(A1!*R`thBciMDY`%eaX6fdAlsz-NeU#+ou$ht@v>aNvyQH zR|ii-9m)YBj-P!c)Rm1`HCyuNBQ9(;ppu3LC59~q!Pf2z~Z8wChDzPIc8 z2{LnqJy2KVOt0Jc#YQ3{E%7vKHSrFdcD0$X#}yBjABgaE*td*lY3=A1FSjbrs(7x8 zZ}aP;7k`8V6NPi!2^MFR+)$C=m0Oz_oPmxziQq!8J+F`JMKZ;E#%rOr9nQ-wUEvp= zvL%mQ%fSLF23mXhRnLRNo5Aj~+!XIkRcBrXWS5^?)2Ed&Ea`l1xN4E;hZ~#LDW&q0 zWz86zZs}^X#KMzV>DN(;C~T*LFqQlvYy`aFeG)TgK4E5L=C;sxATh|yX9JiqQm2fFaY`)@lL54SxNZTp$MbTF ze!JP6bA4i{mCGT_Cm+_9I~DJSh@FwviTHI!A8prbG7;suGxIt?_@&!chazPl$B)~M zhlw2e>jYbia7BT{Rsh)ey*-SNhC%g)q5SwvR^3O9UE{Yrx6*W#N2_qMrBB&91_{>& z^7_;sObSpE^Vr^394vV_**-dI_I2qg2F)#{ECGhV77l;W=UzkuUWMb2A)swuU683< zdwLmbQ!r)gdB_s$_c)mGmtU`W!t|=6sg7BL;LB(tK6|b538woC9A(!5M`<MeO`-}RGTk3)0avc^!fjqlYQo!ZS>LfMV@#0?nVr7Rlnlh zEl+oEn$l`~nv!ckmRgN}xC`FL_$E3vZ+M1G?RoQvdG46zfZGH^ig!Up>^ zNx}(r`F-BDS28rOc1NEbq2SnXJ3adx#$W`BoVF7e9fMEK_~E^0wwzjC>Bp&AU@=Y+ zOFu$*`uy7#J9jV_&86w&9iPUtY=A?oMNBeT?C{LTa631$xK zWx9gT?~7u&nPl-TwAm_a2alwbKkNd9`iQst2^X zJEl;T<&xd;grfw-NDKAE4JDV1jwCV*Zm6iwx%5HKr;6l!+|6r9@9P$IHDSsze0seMC?SbY!aASFcrG@0SIUBw< zGYCY<=gr!j1b3Gccf)bAClFj!9T}oV^5vd36H!;UxJ@ zp$y#JB4E}7$Adiwnz#ihuJV*`%#NtNXBz#Ss%ZCFOZ9=mua~ckG8j*mek|NN2S*8*IBpgpzF@gV7a!`2BI-a;r8%mDjTN)?)xjOpNmY zR7Gl7=_Y$V+vJkbCyn-{`9V<;Zta3xXHMvDH7dw_zq2B@=6_Bvw{6gqlb@zN*gb|A zKgcNXvenLc`Xd%0`|!DbQIAUTXpv(sApv*WC!XPq!YUG7<%w2Z>*Ph4IEk40CUbb( zY-3SX_E2`2c9(P=JsC{wRJ2_9z;b+EJN%V(#ZFMw`R5{4!=dXwr2?O9c}dAM$zmsu zy-`gVB40R@-6p-$+>^szqb~IQ)bJd4{v1q<*EO))0H)bS>ZTjmVh0!gq{=v zzF+vJ+)%LOBfCr9r>*U{Z#^PL>AUCmAb|lw1DF0Yq@p58m8ryftbskv^UX|55sVe> z%Giyx3VXDhY2GV531=<4jOBA#Azp8rB!Tl}F7JclpM3^z8XM;`#M$U215fT`0#?f< zW<~?hJE+jk=#Nn4d4dqFridmZBw;l1m?%Pmn0)*RYfaz}JNb5MxCN%AR0~gZ27iI* zq;Gwt#(tpa+M0DCgE>*<=EM>HHYri-l)RxRI~B7$(j(H%v!x5N3UMtLw|;$K!*cdp ze>)eEwEMHUux29Kwyz4{k>AQ&%hhAFudy?1t)x(kbKHnHdeeKdTWs_xdrN!#dQUji z1U|r2^B{^#o9P?EtI=Ga0}DV67G%T*lFSceiEPJ#cIL1 z=Iz$jj07=ao|3E1wwEq`irsx5JAE7+5vHl|wDWD*`@)#=QH9Z?$le>*S1?Lpb9Z!2OmwSeW|tK3__iQhutGL|DN&+Y>a zWEyb+$5T5d#oE^__nRwoXX=7X0hSyIKGy!C`JOknt(!gH@Nx2G(3;G*yYo(m&tGII zY4@kVOW@oY?mCt74WGH5%<|Y^cU$jiI&djubhYP6;JKOk%!;+EZDEz zD%Q#;oqxw}PItocxn-vO_J4d9%CGPE3Y^VfB%R5^#8Ck}$nHVn@&HxPFasAbNo$1M zRAb^5kJykfPu4Zy_OySWrYoOu=D(XAesL~)Tt#9`?&|P6)xaaRuP-&>U}UiYoqD`P zc?D?UHoQP+e8gIE+cEasrFF5@)shYGj3&go_1B#QZvIu+oO06qn{I4Z(dV=K+nH~^ z4ttdioa%lXd8?Iy=_zm&Qlp%ILp{hXpytku1vhz_TsdS44#)vdEK5J^|6&~vu(vuh zGJkL1v)Ajz**}}@Nqb)-xK6<4;tPgeA&+3-kU(($UtJelzRoQg*Rpe~o?^_5?MXP`{I zcX$gw@TgYML5X^m-$1jjM?gpZ8e8mkUf|JaS)9gRt~etJcoJcbn9huR;02$l`N}u9 zW|vo&w;&MP0s~Z zX|sT>=u3BgZ;=JghytBFVb!~38#zLz0F{Ez7seb16j-Wv8#qC$0o1qXO6qOkU^u8e zgq)Ss7y>+{a>=UQnL4n8B7x^!gR4r$eyiqHpam3xz-$27V4&ayN_3?+&6qfrfLexU z_xj%kmb9Tj*ROn)C)MrX3N&mqC`N-~G*t}E3<5knTI2gc&{iT~Y0b4QemMeIbb)#?;1+fO>|9nRMwUOI`enKP3fLlV@aiiNu@Q6 Date: Sun, 15 Jun 2025 10:22:15 -0700 Subject: [PATCH 137/581] wip --- .../SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 4e740187..fe5b2e2e 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -11,7 +11,7 @@ to CloudKit. However, distributing your app's schema across many devices is an i to make, and so an abundance of care must be taken to make sure all devices remain consistent and capable of communicating with each other. Please read the documentation closely and thoroughly to make sure you understand how to best prepare your app for cloud synchronization. - + - [Setting up your project](#Setting-up-your-project) - [Setting up a SyncEngine](#Setting-up-a-SyncEngine) - [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) @@ -27,11 +27,14 @@ to make sure you understand how to best prepare your app for cloud synchronizati - [Sharing foreign key relationships](#Sharing-foreign-key-relationships) - [One-to-many relationships](#One-to-many-relationships) - [Many-to-many relationships](#Many-to-many-relationships) + - [One-to-"at most one" relationships](#One-to-at-most-one-relationships) + - [Assets](#Assets) - [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) - [How SharingGRDB handles distributed schema scenarios](#How-SharingGRDB-handles-distributed-schema-scenarios) - [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) - [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) - [Add primary key to all tables](#Add-primary-key-to-all-tables) + ## Setting up your project The steps to set up your SharingGRDB project for CloudKit synchronization are the From dbdd05059a0d33998b927cb2eef5667194e194b5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 16 Jun 2025 13:59:26 -0700 Subject: [PATCH 138/581] some docs --- Examples/Reminders/RemindersLists.swift | 45 ++++++--- Examples/Reminders/Schema.swift | 38 ++++---- README.md | 30 ++++++ .../SharingGRDBCore/CloudKit/Metadata.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 78 +++++++++++++-- .../Documentation.docc/Articles/CloudKit.md | 97 +++++++++++-------- 6 files changed, 210 insertions(+), 80 deletions(-) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 9e10d644..4952483a 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -113,18 +113,36 @@ class RemindersListsModel { try database.write { db in var ids = remindersLists.map(\.remindersList.id) ids.move(fromOffsets: source, toOffset: destination) - try RemindersList - .where { $0.id.in(ids) } - .update { - let ids = Array(ids.enumerated()) - let (first, rest) = (ids.first!, ids.dropFirst()) - $0.position = - rest - .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in - cases.when(id.element, then: id.offset) - } - .else($0.position) - } + for (offset, id) in ids.enumerated() { + try RemindersList.find(id) + .update { $0.position = offset } + .execute(db) + } +// .find(ids) +// try RemindersList +// .where { $0.id.in(ids) } +// .update { +// let ids = Array(ids.enumerated()) +// let (first, rest) = (ids.first!, ids.dropFirst()) +// $0.position = +// rest +// .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in +// cases.when(id.element, then: id.offset) +// } +// .else($0.position) +// } +// .execute(db) + } + } + } + + func deleteTags(indexSet: IndexSet) { + withErrorReporting { + let tagIDs = indexSet.map { tags[$0].id } + try database.write { db in + try Tag + .where { $0.id.in(tagIDs) } + .delete() .execute(db) } } @@ -271,6 +289,9 @@ struct RemindersListsView: View { } .foregroundStyle(.primary) } + .onDelete { indexSet in + model.deleteTags(indexSet: indexSet) + } } header: { Text("Tags") .font(.system(.title2, design: .rounded, weight: .bold)) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 6ae6385f..0be1c06d 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -143,7 +143,7 @@ func appDatabase() throws -> any DatabaseWriter { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), "position" INTEGER NOT NULL DEFAULT 0, - "title" TEXT NOT NULL + "title" TEXT NOT NULL DEFAULT '' ) STRICT """ ) @@ -153,7 +153,9 @@ func appDatabase() throws -> any DatabaseWriter { CREATE TABLE "remindersListAssets" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "coverImage" BLOB, - "remindersListID" TEXT NOT NULL UNIQUE REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" TEXT NOT NULL + DEFAULT '00000000-0000-0000-0000-000000000000' + REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -165,11 +167,11 @@ func appDatabase() throws -> any DatabaseWriter { "dueDate" TEXT, "isCompleted" INTEGER NOT NULL DEFAULT 0, "isFlagged" INTEGER NOT NULL DEFAULT 0, - "notes" TEXT, + "notes" TEXT NOT NULL DEFAULT '', "position" INTEGER NOT NULL DEFAULT 0, "priority" INTEGER, - "remindersListID" TEXT NOT NULL, - "title" TEXT NOT NULL, + "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + "title" TEXT NOT NULL DEFAULT '', FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT @@ -180,7 +182,7 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "tags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL COLLATE NOCASE + "title" TEXT NOT NULL DEFAULT '' ) STRICT """ ) @@ -189,8 +191,8 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "remindersTags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "reminderID" TEXT NOT NULL, - "tagID" TEXT NOT NULL, + "reminderID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + "tagID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', FOREIGN KEY("reminderID") REFERENCES "reminders"("id") ON DELETE CASCADE, FOREIGN KEY("tagID") REFERENCES "tags"("id") ON DELETE CASCADE @@ -219,16 +221,16 @@ func appDatabase() throws -> any DatabaseWriter { .where { $0.id.eq(new.id) } }) .execute(db) - try RemindersList.createTemporaryTrigger( - after: .delete { _ in - RemindersList.insert { - RemindersList.Draft(color: .blue, title: "Personal") - } - } when: { _ in - RemindersList.count().eq(0) - } - ) - .execute(db) +// try RemindersList.createTemporaryTrigger( +// after: .delete { _ in +// RemindersList.insert { +// RemindersList.Draft(color: .blue, title: "Personal") +// } +// } when: { _ in +// RemindersList.count().eq(0) +// } +// ) +// .execute(db) } return database diff --git a/README.md b/README.md index cd8e655b..fb782f90 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,34 @@ try modelContext.save() > For more information on how SharingGRDB compares to SwiftData, see > [Comparison with SwiftData][comparison-swiftdata-article]. +Further, if you want to synchronize the local database to CloudKit so that it is available on +devices, simply configure a `SyncEngine` in the entry point of the app: + +```swift +@main +struct MyApp: App { + init() { + prepareDependencies { + $0.defaultDatabase = try! appDatabase() + $0.defaultSyncEngine = SyncEngine( + container: CKContainer( + identifier: "iCloud.co.mycompany.MyApp" + ), + database: $0.defaultDatabase, + tables: [ + /* ... */ + ] + ) + } + } + // ... +} +``` + +> [!NOTE] +> For more information on synchronizing the database to CloudKit and sharing records with iCloud +> users, see [CloudKit Synchronization]. + This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read the [articles][articles] below to learn how to best utilize this library: @@ -253,6 +281,7 @@ the [articles][articles] below to learn how to best utilize this library: * [Observing changes to model data][observing-article] * [Preparing a SQLite database][preparing-db-article] * [Dynamic queries][dynamic-queries-article] + * [CloudKit Synchronization] * [Comparison with SwiftData][comparison-swiftdata-article] [observing-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/observing @@ -261,6 +290,7 @@ the [articles][articles] below to learn how to best utilize this library: [comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/comparisonwithswiftdata [fetching-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetching [preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/preparingdatabase +[CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/cloudkit [fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetchall [fetchone-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetchone diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index ba4c028e..cf5c01a5 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -35,7 +35,7 @@ extension Metadata { ifNotExists: true, after: .delete { SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_willUpdate(\($0.recordName))" + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didDelete(\($0.recordName))" ) } when: { _ in SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 25778bda..835c7b29 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -84,6 +84,7 @@ public final class SyncEngine: Sendable { metadatabaseURL: URL, tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] ) throws { + try validateSchema(tables: tables, database: database) // TODO: Explain why / link to documentation? precondition( !database.configuration.foreignKeysEnabled, @@ -156,7 +157,7 @@ public final class SyncEngine: Sendable { } db.add(function: .isUpdatingWithServerRecord) db.add(function: .didUpdate(syncEngine: self)) - db.add(function: .willDelete(syncEngine: self)) + db.add(function: .didDelete(syncEngine: self)) try Metadata.createTriggers(tables: self.tables, db: db) @@ -230,7 +231,7 @@ public final class SyncEngine: Sendable { for table in self.tables { try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } - db.remove(function: .willDelete(syncEngine: self)) + db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .isUpdatingWithServerRecord) } @@ -290,7 +291,7 @@ public final class SyncEngine: Sendable { ) } - func willDelete(recordName: String) { + func didDelete(recordName: String) { let zoneID = zoneID(for: recordName) let syncEngine = syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -602,8 +603,7 @@ extension SyncEngine: CKSyncEngineDelegate { self.upsertFromServerRecord(record) self.refreshLastKnownServerRecord(record) } - if - let shareReference = record.share, + if let shareReference = record.share, let shareRecord = try? await container.database(for: shareReference.recordID) .record(for: shareReference.recordID), let share = shareRecord as? CKShare @@ -631,6 +631,7 @@ extension SyncEngine: CKSyncEngineDelegate { try deleteShare(recordID: recordID, recordType: recordType) } } else { + // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? reportIssue( .sqliteDataCloudKitFailure.appending( """ @@ -747,6 +748,7 @@ extension SyncEngine: CKSyncEngineDelegate { ?? nil guard let table = tablesByName[record.recordType] else { + // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? reportIssue( .sqliteDataCloudKitFailure.appending( """ @@ -822,7 +824,7 @@ extension SyncEngine: CKSyncEngineDelegate { private func refreshLastKnownServerRecord(_ record: CKRecord) { $isUpdatingWithServerRecord.withValue(true) { let metadata = metadataFor(recordID: record.recordID) - + func updateLastKnownServerRecord() { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in @@ -833,7 +835,7 @@ extension SyncEngine: CKSyncEngineDelegate { } } } - + if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { if let recordDate = record.modificationDate, lastKnownDate < recordDate { updateLastKnownServerRecord() @@ -862,9 +864,9 @@ extension DatabaseFunction { } } - fileprivate static func willDelete(syncEngine: SyncEngine) -> Self { - return Self("willDelete") { recordName in - syncEngine.willDelete(recordName: recordName) + fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { + return Self("didDelete") { recordName in + syncEngine.didDelete(recordName: recordName) } } @@ -958,3 +960,59 @@ extension Database { .execute(self) } } + +private func validateSchema( + tables: [any PrimaryKeyedTable.Type], + database: any DatabaseReader +) throws { + try database.read { db in + for table in tables { + // TODO: write tests for this + let columnsWithUniqueConstraints = + try SQLQueryExpression( + """ + SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + WHERE "unique" = 1 AND "origin" <> 'pk' + """, + as: String.self + ) + .fetchAll(db) + if !columnsWithUniqueConstraints.isEmpty { + throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) + } + + // TODO: write tests for this + let nonNullColumnsWithNoDefault = + try SQLQueryExpression( + """ + SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) + WHERE "notnull" = 1 AND "dflt_value" IS NULL + """, + as: String.self + ) + .fetchAll(db) + if !nonNullColumnsWithNoDefault.isEmpty { + throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) + } + } + } +} + +public struct UniqueConstraintDisallowed: Error { + let localizedDescription: String + init(table: any PrimaryKeyedTable.Type, columns: [String]) { + localizedDescription = """ + Table '\(table.tableName)' has column\(columns.count == 1 ? "" : "s") with unique \ + constraints: \(columns.map { "'\($0)'" }.joined(separator: ", ")) + """ + } +} +public struct NonNullColumnMustHaveDefault: Error { + let localizedDescription: String + init(table: any PrimaryKeyedTable.Type, columns: [String]) { + localizedDescription = """ + Table '\(table.tableName)' has non-null column\(columns.count == 1 ? "" : "s") with no \ + default: \(columns.map { "'\($0)'" }.joined(separator: ", ")) + """ + } +} diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index fe5b2e2e..62993c9c 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -19,9 +19,9 @@ to make sure you understand how to best prepare your app for cloud synchronizati - [Primary keys on every table](#Primary-keys-on-every-table) - [Default values for columns](#Default-values-for-columns) - [Unique constraints](#Unique-constraints) - - [Backwards compatible migrations](#Backwards-compatible-migrations) - [Foreign key relationships](#Foreign-key-relationships) - - [Record conflicts](#Record-conflicts) + - [Record conflicts](#Record-conflicts) + - [Backwards compatible migrations](#Backwards-compatible-migrations) - [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) - [Sharing root records](#Sharing-root-records) - [Sharing foreign key relationships](#Sharing-foreign-key-relationships) @@ -135,6 +135,8 @@ CREATE TABLE "reminders" ( ) ``` +> Tip: The "ON CONFLICT REPLACE" clause must be placed directly after "NOT NULL". + This will make it possible to create new records using the `Draft` type afforded to primary keyed tables without needing to specify an `id`: @@ -159,7 +161,7 @@ tables they are joining. For example, a `ReminderTag` table that joins reminders designed like so: ```sql -CREATE TABLE "reminders" ( +CREATE TABLE "reminderTags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE @@ -174,21 +176,31 @@ facilitate synchronizing to CloudKit. > Important: All columns must have a default in order to allow for multiple devices to run your > app with different versions of the schema. - +Your tables' schemas should be defined to provide a default for every non-null column. To see why +this is necessary, consider if device A is running with a schema in which `Reminder` has an +`isFlagged` column and device B is running with a schema that does not. When device B creates a +record without the `isFlagged` value, and that record is synchronized to device A, it will fail to +insert into the database because there is not value for `isFlagged`. + +For this reason all columns in your schema must have a default value, and this will be validated +when a ``SyncEngine`` is first created. If a non-null column without a default is detected, +a ``NonNullColumnMustHaveDefault`` error will be thrown. #### Unique constraints > Important: SQLite tables cannot have "UNIQUE" constraints on their columns in order to allow > for distributed creation of records. - - -#### Backwards compatible migrations +Tables with unique constraints on their columns, other than on the primary key, cannot be +synchronized. As an example, suppose you have a `Tag` table with a unique constraint on the +`title` column. It is not clear how the application should handle if two different devices create +a tag with the title "Family" at the same time. When the two devices synchronize their data +they will have a conflict on the uniqueness constraint, but it would not be correct to +discard one of the tags. -> Important: Database migrations should be done carefully and with full backwards compatibility -> in mind in order to support multiple devices running with different schema versions. - - +For this reason uniqueness constraints are not allowed in schemas, and this will be validated +when a ``SyncEngine`` is first created. If a uniqueness constraint is detected a +``UniqueConstraintDisallowed`` error will be thrown. #### Foreign key relationships @@ -256,7 +268,7 @@ CREATE TABLE "reminders" ( i.e. when a reminders list is deleted, all of its associated reminders will also be deleted, and everything will be synchronized to all devices. -#### Record conflicts +## Record conflicts > Important: Conflicts are handled automatically by letting most recently edited records overwrite > older records. @@ -271,6 +283,13 @@ There is no per-field synchronization, nor is there more advanced CRDT synchroni allow for these kinds of strategies in the future, but for now "last edit wins" is the only strategy available and we feel serves the needs of the most number of people. +## Backwards compatible migrations + +> Important: Database migrations should be done carefully and with full backwards compatibility +> in mind in order to support multiple devices running with different schema versions. + + + ## Sharing records with other iCloud users SharingGRDB provides the tools necessary to share a record with another iCloud user so that @@ -325,11 +344,11 @@ configure how they want to share the record. A record can be _unshared_ by prese #### Sharing root records > Important: It is only possible to share "root" records, that is, records with no -> non-optional foreign keys. +> foreign keys. -A record can be shared only if it is a "root" record. That means it cannot have any non-optional -foreign keys. As an example, the following `RemindersList` table is a root record because it does -not have any fields pointing to other tables: +A record can be shared only if it is a "root" record. That means it cannot have any +foreign keys whatsoever. As an example, the following `RemindersList` table is a root record because +it does not have any fields pointing to other tables: ```swift @Table @@ -354,12 +373,13 @@ struct Reminder: Identifiable { Such records cannot be shared because it is not appropriate to also share the parent record (i.e. the reminders list). For example, suppose you have a list named "Personal" with a reminder -"Get milk". You share this reminder with someone, who then wants to rename the list to "Life". -Would you want your list to also be renamed even though you did not explicitly share the list? +"Get milk". If you share this reminder with someone, and they decide to reassign the reminder to +their "Life" list, what should happen? Should their list be synchronized to your device? +Or what if they rename your personal list? Should that also rename the list on your device? Or what if they delete the list? Would you want that to delete your list and all the reminders in the list? -For those reasons it is not possible to share non-root records, like reminders. Instead, you can +For these reasons it is not possible to share non-root records, like reminders. Instead, you can share root records, like reminders lists. If you do invoke ``SyncEngine/share(record:configure:)`` with a non-root record, a ``SyncEngine/CantShareRecordWithParent`` error will be thrown. @@ -373,19 +393,18 @@ For a more complex example, consider the following diagrammatic schema for a rem In this schema, a `RemindersList` can have many `Reminder`s, can have a `CoverImage`, and a `Reminder` can have multiple `Tag`s, and vice-versa. The only table in this diagram that constitutes -a "root" is `RemindersList`. It is the only one with no non-optional foreign key relationships. +a "root" is `RemindersList`. It is the only one with no foreign key relationships. None of `Reminder`, `CoverImage`, `Tag` or `ReminderTag` can be directly shared on their own because they are not root tables. #### Sharing foreign key relationships > Important: Foreign key relationships are automatically synchronized, but only if the related -> record has a single non-optional foreign key without a uniqueness constraint. Records with -> multiple foreign keys or uniqueness constraints cannot be synchronized. +> record has a single foreign key. Records with multiple foreign keys cannot be synchronized. Relationships between models will automatically be shared when sharing a root record, but with some limitations. An associated record of a shared record will only be shared if it has exactly -one non-optional foreign key pointing to the root shared record, whether directly or indirectly +one foreign key pointing to the root shared record, whether directly or indirectly through other records satisfying this property. Below we describe some of the most common types of relationships in SQL databases, as well as @@ -414,8 +433,7 @@ struct Reminder: Identifiable { ``` Since `RemindersList` is a [root record](#Sharing-root-records) it can be shared, and since -`Reminder` has only one non-optional foreign key pointing to `RemindersList`, it too will be -shared. +`Reminder` has only one foreign key pointing to `RemindersList`, it too will be shared. Further, suppose there was a `ChildReminder` table that had a single foreign key pointing to a `Reminder`: @@ -440,20 +458,17 @@ As a more complex example, consider the following diagrammatic schema: In this schema, a `RemindersList` can have many `Reminder`s and a `CoverImage`, and a `Reminder` can have many `ChildReminder`s. Sharing a `RemindersList` will share all associated reminders, cover image, and even child reminderes. The child reminders are synchronized because it has a -single non-optional foreign key pointing to a table that also has a single non-optional foreign -key pointing to the root record. +single foreign key pointing to a table that also has a single foreign key pointing to the root +record. ##### Many-to-many relationships Many-to-many relationships pose a significant problem to sharing and cannot be supported. If a -table has multiple non-optional foreign keys, then it will not be shared even if one of those +table has multiple foreign keys, then it will not be shared even if one of those foreign keys points to the shared record. -> Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing -> many-to-many relationships. - -To see why many-to-many relationships can be problematic, suppose we had a many-to-many association -of a `Tag` table to `Reminder` via a `ReminderTag` join table: +As an example, suppose we had a many-to-many association of a `Tag` table to `Reminder` via a +`ReminderTag` join table: ```swift @Table @@ -473,16 +488,20 @@ In diagrammatic form, this schema looks like the following: ![Synchronizing many-to-many relationships](sync-diagram-many-to-many.png) -The `ReminderTag` records will _not_ be shared because it has two non-optional foreign key +The `ReminderTag` records will _not_ be shared because it has two foreign key relationships, represented by the two arrows leaving the `ReminderTag` node. As a consequence, the `Tag` records will also not be shared. Sharing these records cannot be done in a consistent and logical manner. -To see the problem, suppose you share a "Personal" list with someone, which holds a "Get milk" -reminder, and that reminder has a "weekend" tag associated with it. If the tag were shared with your -friend, then what happens when they delete the tag? Would it be appropriate to delete that tag from -all of your reminders, even the ones that were not shared? For these reasons, and more, records -with multiple non-optional foreign keys cannot be shared with a record. +> Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing +> many-to-many relationships. This is also how the Reminders app works on Apple's platforms. +> Sharing a list of reminders with another use does not share its tags with that user. + +To see why this is an acceptable limitation, suppose you share a "Personal" list with someone, which +holds a "Get milk" reminder, and that reminder has a "weekend" tag associated with it. If the tag +were shared with your friend, then what happens when they delete the tag? Would it be appropriate to +delete that tag from all of your reminders, even the ones that were not shared? For these reasons, +and more, records with multiple foreign keys cannot be shared with a record. If you want to support many tags associated with a single reminder, you will have no choice but to turn it into a one-to-many relationship so that each tag belongs to exactly one reminder: From a7e3a73417d06578eef8911ace32c69231e5bae7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 16 Jun 2025 14:00:39 -0700 Subject: [PATCH 139/581] wip --- Examples/Reminders/RemindersDetail.swift | 18 +++++------------- Examples/Reminders/RemindersLists.swift | 14 -------------- 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index ed3eca97..a6fee706 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -49,19 +49,11 @@ class RemindersDetailModel: HashableObject { try database.write { db in var ids = reminderRows.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) - try Reminder - .where { $0.id.in(ids) } - .update { - let ids = Array(ids.enumerated()) - let (first, rest) = (ids.first!, ids.dropFirst()) - $0.position = - rest - .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in - cases.when(id.element, then: id.offset) - } - .else($0.position) - } - .execute(db) + for (offset, id) in ids.enumerated() { + try Reminder.find(id) + .update { $0.position = offset } + .execute(db) + } } } $ordering.withLock { $0 = .manual } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 4952483a..751a4841 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -118,20 +118,6 @@ class RemindersListsModel { .update { $0.position = offset } .execute(db) } -// .find(ids) -// try RemindersList -// .where { $0.id.in(ids) } -// .update { -// let ids = Array(ids.enumerated()) -// let (first, rest) = (ids.first!, ids.dropFirst()) -// $0.position = -// rest -// .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in -// cases.when(id.element, then: id.offset) -// } -// .else($0.position) -// } -// .execute(db) } } } From e80291f948801df91b06ff44524c2d88b19cab2a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 17 Jun 2025 12:25:20 -0700 Subject: [PATCH 140/581] wip --- Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 62993c9c..ba90807e 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -563,6 +563,8 @@ create multiple cover images pointing to the same reminders list. +## Unit testing and Xcode previews + ## Preparing an existing schema for synchronization From dc6751536a9982d9ce203c273cd14bd6fcdb38d5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 18 Jun 2025 11:37:43 -0700 Subject: [PATCH 141/581] wip --- Examples/Reminders/Schema.swift | 20 +- Package.resolved | 22 +- .../SharingGRDBCore/CloudKit/ForeignKey.swift | 160 ++++----- .../SharingGRDBCore/CloudKit/Metadata.swift | 119 ++++--- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 9 +- .../Documentation.docc/Articles/CloudKit.md | 11 +- .../CloudKitTests/CloudKitTests.swift | 15 +- .../CloudKitTests/ForeignKeyTests.swift | 1 + .../CloudKitTests/MetadataTests.swift | 1 + .../CloudKitTests/TriggerTests.swift | 330 ++---------------- Tests/SharingGRDBTests/Internal/Schema.swift | 10 +- 11 files changed, 221 insertions(+), 477 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 0be1c06d..0fc7bf01 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -221,16 +221,16 @@ func appDatabase() throws -> any DatabaseWriter { .where { $0.id.eq(new.id) } }) .execute(db) -// try RemindersList.createTemporaryTrigger( -// after: .delete { _ in -// RemindersList.insert { -// RemindersList.Draft(color: .blue, title: "Personal") -// } -// } when: { _ in -// RemindersList.count().eq(0) -// } -// ) -// .execute(db) + try RemindersList.createTemporaryTrigger( + after: .delete { _ in + RemindersList.insert { + RemindersList.Draft(color: .blue, title: "Personal") + } + } when: { _ in + RemindersList.count().eq(0) + } + ) + .execute(db) } return database diff --git a/Package.resolved b/Package.resolved index 17b9d627..cc3292ab 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a534be697c5a5dde86d3f510df8d5ac32f64626136b0d5a55819d56fc0295499", + "originHash" : "eeb146033872c3a335797e08df619408f2389e941d3afa8f8b3fdae982a5f91e", "pins" : [ { "identity" : "combine-schedulers", @@ -19,15 +19,6 @@ "version" : "7.4.1" } }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", - "version" : "1.7.0" - } - }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -100,15 +91,6 @@ "version" : "1.1.1" } }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21", - "version" : "2.3.1" - } - }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", @@ -142,7 +124,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "temp-triggers", - "revision" : "4b33440ba8f3f797dd66f1e15ff5c4fc3cef2492" + "revision" : "7b81eb46a10afd42d3bf40448c78d0cfbd59d56a" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index 43e19b65..767f1348 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -209,86 +209,86 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { } func dropTriggers(for _: T.Type, db: Database) throws { -// switch onDelete { -// case .cascade: -// try SQLQueryExpression( -// """ -// DROP TRIGGER -// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteCascade" -// """ -// ) -// .execute(db) -// -// case .setNull: -// try SQLQueryExpression( -// """ -// DROP TRIGGER -// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetNull" -// """ -// ) -// .execute(db) -// -// case .setDefault: -// try SQLQueryExpression( -// """ -// DROP TRIGGER -// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetDefault" -// """ -// ) -// .execute(db) -// -// case .restrict: -// try SQLQueryExpression( -// """ -// DROP TRIGGER -// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteRestrict" -// """ -// ) -// .execute(db) -// -// case .noAction: -// break -// } + switch onDelete { + case .cascade: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteCascade" + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetNull" + """ + ) + .execute(db) + + case .setDefault: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetDefault" + """ + ) + .execute(db) + + case .restrict: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteRestrict" + """ + ) + .execute(db) + + case .noAction: + break + } -// switch onUpdate { -// case .cascade: -// try SQLQueryExpression( -// """ -// DROP TRIGGER -// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateCascade" -// """ -// ) -// .execute(db) -// -// case .setNull: -// try SQLQueryExpression( -// """ -// DROP TRIGGER -// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetNull" -// """ -// ) -// .execute(db) -// -// case .setDefault: -// try SQLQueryExpression( -// """ -// DROP TRIGGER -// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetDefault" -// """ -// ) -// .execute(db) -// -// case .restrict: -// try SQLQueryExpression( -// """ -// DROP TRIGGER -// "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateRestrict" -// """ -// ) -// .execute(db) -// -// case .noAction: -// break -// } + switch onUpdate { + case .cascade: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateCascade" + """ + ) + .execute(db) + + case .setNull: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetNull" + """ + ) + .execute(db) + + case .setDefault: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetDefault" + """ + ) + .execute(db) + + case .restrict: + try SQLQueryExpression( + """ + DROP TRIGGER + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateRestrict" + """ + ) + .execute(db) + + case .noAction: + break + } } } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index cf5c01a5..427c6c00 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -3,45 +3,55 @@ import StructuredQueriesCore @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata { + fileprivate static let afterInsertTrigger = createTemporaryTrigger( + ifNotExists: true, + after: .insert { + SQLQueryExpression( + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\($0.recordName))" + ) + } when: { _ in + SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } + ) + + fileprivate static let afterUpdateTrigger = createTemporaryTrigger( + ifNotExists: true, + after: .update { _, new in + SQLQueryExpression( + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(new.recordName))" + ) + } when: { _, _ in + SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } + ) + + fileprivate static let afterDeleteTrigger = createTemporaryTrigger( + ifNotExists: true, + after: .delete { + SQLQueryExpression( + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didDelete(\($0.recordName))" + ) + } when: { _ in + SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } + ) + static func createTriggers( tables: [any PrimaryKeyedTable.Type], db: Database ) throws { - try createTemporaryTrigger( - ifNotExists: true, - after: .insert { - SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\($0.recordName))" - ) - } when: { _ in - SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - ) - .execute(db) - - try createTemporaryTrigger( - ifNotExists: true, - after: .update { _, new in - SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(new.recordName))" - ) - } when: { _, _ in - SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - ) - .execute(db) + try afterInsertTrigger.execute(db) + try afterUpdateTrigger.execute(db) + try afterDeleteTrigger.execute(db) + } - try createTemporaryTrigger( - ifNotExists: true, - after: .delete { - SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didDelete(\($0.recordName))" - ) - } when: { _ in - SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - ) - .execute(db) + static func dropTriggers( + tables: [any PrimaryKeyedTable.Type], + db: Database + ) throws { + try afterInsertTrigger.drop().execute(db) + try afterUpdateTrigger.drop().execute(db) + try afterDeleteTrigger.drop().execute(db) } static func createTriggers>( @@ -93,13 +103,24 @@ extension Metadata { ) .execute(db) - try T.createTemporaryTrigger( - ifNotExists: true, - after: .delete { old in - Metadata - .where { $0.recordName.eq(old.primaryKey) } - .delete() - } + try T.createDeleteTrigger.execute(db) + } + + static func dropTriggers>( + for _: T.Type, + db: Database + ) throws { + try T.createDeleteTrigger.drop().execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER \(updateTriggerName(for: T.self)) + """ + ) + .execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER \(insertTriggerName(for: T.self)) + """ ) .execute(db) } @@ -119,12 +140,18 @@ extension Metadata { #""\#(raw: .sqliteDataCloudKitSchemaName)_\#(raw: T.tableName)_metadataUpdates""# ) } +} - private static func deleteTriggerName( - for _: T.Type - ) -> SQLQueryExpression { - SQLQueryExpression( - #""\#(raw: .sqliteDataCloudKitSchemaName)_\#(raw: T.tableName)_metadataDeletes""# +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension PrimaryKeyedTable { + fileprivate static var createDeleteTrigger: TemporaryTrigger { + createTemporaryTrigger( + ifNotExists: true, + after: .delete { old in + Metadata + .where { $0.recordName.eq(old.primaryKey) } + .delete() + } ) } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 835c7b29..0ecb37e3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -34,7 +34,7 @@ public final class SyncEngine: Sendable { CKSyncEngine.Configuration( database: container.privateCloudDatabase, stateSerialization: try? database.read { db in // TODO: write test for this - try StateSerialization.find(.private).select(\.data).fetchOne(db) + try StateSerialization.find(CKDatabase.Scope.private).select(\.data).fetchOne(db) }, delegate: syncEngine ) @@ -43,7 +43,7 @@ public final class SyncEngine: Sendable { CKSyncEngine.Configuration( database: container.sharedCloudDatabase, stateSerialization: try? database.read { db in // TODO: write test for this - try StateSerialization.find(.shared).select(\.data).fetchOne(db) + try StateSerialization.find(CKDatabase.Scope.shared).select(\.data).fetchOne(db) }, delegate: syncEngine ) @@ -231,6 +231,7 @@ public final class SyncEngine: Sendable { for table in self.tables { try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } + try Metadata.dropTriggers(tables: self.tables, db: db) db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .isUpdatingWithServerRecord) @@ -241,7 +242,6 @@ public final class SyncEngine: Sendable { try RecordType.delete().execute(db) try StateSerialization.delete().execute(db) } - _ = await (privateCancellation, sharedCancellation) } @@ -346,9 +346,10 @@ extension PrimaryKeyedTable { db: Database ) throws { let foreignKeys = foreignKeysByTableName[Self.tableName] ?? [] - for foreignKey in foreignKeys { + for foreignKey in foreignKeys.reversed() { try foreignKey.dropTriggers(for: Self.self, db: db) } + try Metadata.dropTriggers(for: Self.self, db: db) } } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index ba90807e..3dfd3b6a 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -27,7 +27,8 @@ to make sure you understand how to best prepare your app for cloud synchronizati - [Sharing foreign key relationships](#Sharing-foreign-key-relationships) - [One-to-many relationships](#One-to-many-relationships) - [Many-to-many relationships](#Many-to-many-relationships) - - [One-to-"at most one" relationships](#One-to-at-most-one-relationships) + - [One-to-"at most one" relationships](#One-to-at-most-one-relationships) + - [Controlling what data is shared](#Controlling-what-data-is-shared) - [Assets](#Assets) - [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) - [How SharingGRDB handles distributed schema scenarios](#How-SharingGRDB-handles-distributed-schema-scenarios) @@ -551,6 +552,10 @@ you will model the relationship as a one-to-many (as described in ) and making sure that in your feature's logic you never create multiple cover images pointing to the same reminders list. +#### Controlling what data is shared + + + ## Assets @@ -578,3 +583,7 @@ create multiple cover images pointing to the same reminders list. + +## Migrating from Swift Data to SharingGRDB + +## Separating schema migrations from data migrations diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index bc40c705..e070c008 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -7,6 +7,7 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { + @MainActor final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() throws { @@ -21,7 +22,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "remindersLists" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL + "title" TEXT NOT NULL DEFAULT '' ) STRICT """ ), @@ -30,8 +31,8 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "users" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "name" TEXT NOT NULL, - "parentUserID" TEXT DEFAULT NULL, + "name" TEXT NOT NULL DEFAULT '', + "parentUserID" TEXT, FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE ) STRICT @@ -43,9 +44,9 @@ extension BaseCloudKitTests { CREATE TABLE "reminders" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "assignedUserID" TEXT, - "title" TEXT NOT NULL, + "title" TEXT NOT NULL DEFAULT '', "parentReminderID" TEXT, - "remindersListID" TEXT NOT NULL, + "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, @@ -125,8 +126,8 @@ extension BaseCloudKitTests { """ [ [0]: "sqlitedata_icloud_didupdate", - [1]: "sqlitedata_icloud_willdelete", - [2]: "sqlitedata_icloud_isupdatingwithserverrecord" + [1]: "sqlitedata_icloud_isupdatingwithserverrecord", + [2]: "sqlitedata_icloud_diddelete" ] """ } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 03ca6b08..2e65edfd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -7,6 +7,7 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { + @MainActor final class ForeignKeyTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteCascade() throws { diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index e9cf0571..0cd12253 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -7,6 +7,7 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { + @MainActor final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func parentRecordName() throws { diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 7f73c0fa..1e4e6d7c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -6,6 +6,7 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { + @MainActor final class TriggerTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func triggers() async throws { @@ -16,33 +17,24 @@ extension BaseCloudKitTests { #""" [ [0]: """ - CREATE TRIGGER "sqlitedata_icloud_metadata_inserts" + CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata@SharingGRDBCore/Metadata.swift:6:69" AFTER INSERT ON "sqlitedata_icloud_metadata" - FOR EACH ROW - WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() - BEGIN - SELECT - sqlitedata_icloud_didUpdate("new"."recordName"); + FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN + SELECT sqlitedata_icloud_didUpdate("new"."recordName"); END """, [1]: """ - CREATE TRIGGER "sqlitedata_icloud_metadata_updates" + CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata@SharingGRDBCore/Metadata.swift:17:69" AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW - WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() - BEGIN - SELECT - sqlitedata_icloud_didUpdate("new"."recordName"); + FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN + SELECT sqlitedata_icloud_didUpdate("new"."recordName"); END """, [2]: """ - CREATE TRIGGER "sqlitedata_icloud_metadata_deletes" - BEFORE DELETE ON "sqlitedata_icloud_metadata" - FOR EACH ROW - WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() - BEGIN - SELECT - sqlitedata_icloud_willDelete("old"."recordName"); + CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata@SharingGRDBCore/Metadata.swift:28:69" + AFTER DELETE ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN + SELECT sqlitedata_icloud_didDelete("old"."recordName"); END """, [3]: """ @@ -96,10 +88,11 @@ extension BaseCloudKitTests { END """, [5]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_metadataDeletes" - AFTER DELETE ON "reminders" FOR EACH ROW BEGIN + CREATE TRIGGER "after_delete_on_reminders@SharingGRDBCore/Metadata.swift:148:27" + AFTER DELETE ON "reminders" + FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE "recordName" = "old"."id"; + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id"); END """, [6]: """ @@ -206,10 +199,11 @@ extension BaseCloudKitTests { END """, [14]: """ - CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataDeletes" - AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN + CREATE TRIGGER "after_delete_on_remindersLists@SharingGRDBCore/Metadata.swift:148:27" + AFTER DELETE ON "remindersLists" + FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE "recordName" = "old"."id"; + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id"); END """, [15]: """ @@ -263,10 +257,11 @@ extension BaseCloudKitTests { END """, [17]: """ - CREATE TRIGGER "sqlitedata_icloud_users_metadataDeletes" - AFTER DELETE ON "users" FOR EACH ROW BEGIN + CREATE TRIGGER "after_delete_on_users@SharingGRDBCore/Metadata.swift:148:27" + AFTER DELETE ON "users" + FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE "recordName" = "old"."id"; + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id"); END """, [18]: """ @@ -296,282 +291,9 @@ extension BaseCloudKitTests { try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } assertInlineSnapshot(of: triggersAfterTearDown, as: .customDump) { - #""" - [ - [0]: """ - CREATE TRIGGER "sqlitedata_icloud_metadata_inserts" - AFTER INSERT ON "sqlitedata_icloud_metadata" - FOR EACH ROW - WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() - BEGIN - SELECT - sqlitedata_icloud_didUpdate("new"."recordName"); - END - """, - [1]: """ - CREATE TRIGGER "sqlitedata_icloud_metadata_updates" - AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW - WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() - BEGIN - SELECT - sqlitedata_icloud_didUpdate("new"."recordName"); - END - """, - [2]: """ - CREATE TRIGGER "sqlitedata_icloud_metadata_deletes" - BEFORE DELETE ON "sqlitedata_icloud_metadata" - FOR EACH ROW - WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() - BEGIN - SELECT - sqlitedata_icloud_willDelete("old"."recordName"); - END - """, - [3]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_metadataInserts" - AFTER INSERT ON "reminders" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'reminders', - "new"."id", - "new"."remindersListID" AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", - "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [4]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_metadataUpdates" - AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'reminders', - "new"."id", - "new"."remindersListID" AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", - "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [5]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_metadataDeletes" - AFTER DELETE ON "reminders" FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE "recordName" = "old"."id"; - END - """, - [6]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" - AFTER DELETE ON "remindersLists" - FOR EACH ROW BEGIN - DELETE FROM "reminders" - WHERE "remindersListID" = "old"."id"; - END - """, - [7]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" - AFTER UPDATE ON "remindersLists" - FOR EACH ROW BEGIN - UPDATE "reminders" - SET "remindersListID" = "new"."id" - WHERE "remindersListID" = "old"."id"; - END - """, - [8]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onDeleteRestrict" - AFTER DELETE ON "reminders" - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "reminders" - WHERE "parentReminderID" = "old"."id"; - END - """, - [9]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onUpdateRestrict" - AFTER UPDATE ON "reminders" - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "reminders" - WHERE "parentReminderID" = "old"."id"; - END - """, - [10]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_users_onDeleteSetNull" - AFTER DELETE ON "users" - FOR EACH ROW BEGIN - UPDATE "reminders" - SET "assignedUserID" = NULL - WHERE "assignedUserID" = "old"."id"; - END - """, - [11]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_users_onUpdateCascade" - AFTER UPDATE ON "users" - FOR EACH ROW BEGIN - UPDATE "reminders" - SET "assignedUserID" = "new"."id" - WHERE "assignedUserID" = "old"."id"; - END - """, - [12]: """ - CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataInserts" - AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'remindersLists', - "new"."id", - NULL AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", - "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [13]: """ - CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataUpdates" - AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'remindersLists', - "new"."id", - NULL AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", - "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [14]: """ - CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataDeletes" - AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE "recordName" = "old"."id"; - END - """, - [15]: """ - CREATE TRIGGER "sqlitedata_icloud_users_metadataInserts" - AFTER INSERT ON "users" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'users', - "new"."id", - NULL AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", - "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [16]: """ - CREATE TRIGGER "sqlitedata_icloud_users_metadataUpdates" - AFTER UPDATE ON "users" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'users', - "new"."id", - NULL AS "foreignKey", - datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", - "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [17]: """ - CREATE TRIGGER "sqlitedata_icloud_users_metadataDeletes" - AFTER DELETE ON "users" FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE "recordName" = "old"."id"; - END - """, - [18]: """ - CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" - AFTER DELETE ON "users" - FOR EACH ROW BEGIN - UPDATE "users" - SET "parentUserID" = NULL - WHERE "parentUserID" = "old"."id"; - END - """, - [19]: """ - CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" - AFTER UPDATE ON "users" - FOR EACH ROW BEGIN - UPDATE "users" - SET "parentUserID" = "new"."id" - WHERE "parentUserID" = "old"."id"; - END - """ - ] - """# + """ + [] + """ } try await syncEngine.setUpSyncEngine() diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index ade7c4de..8c5fe6d3 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -29,7 +29,7 @@ func database() throws -> DatabasePool { """ CREATE TABLE "remindersLists" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL + "title" TEXT NOT NULL DEFAULT '' ) STRICT """ ) @@ -38,8 +38,8 @@ func database() throws -> DatabasePool { """ CREATE TABLE "users" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "name" TEXT NOT NULL, - "parentUserID" TEXT DEFAULT NULL, + "name" TEXT NOT NULL DEFAULT '', + "parentUserID" TEXT, FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE ) STRICT @@ -51,9 +51,9 @@ func database() throws -> DatabasePool { CREATE TABLE "reminders" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "assignedUserID" TEXT, - "title" TEXT NOT NULL, + "title" TEXT NOT NULL DEFAULT '', "parentReminderID" TEXT, - "remindersListID" TEXT NOT NULL, + "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, From e4bf4a142c44c5c14461e572df88c465e345be3d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 18 Jun 2025 14:34:13 -0700 Subject: [PATCH 142/581] wip --- Examples/Examples.xcodeproj/project.pbxproj | 27 +++++++++--------- .../xcshareddata/swiftpm/Package.resolved | 15 ++++++++-- Package.resolved | 9 ++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 28 +++++++++---------- 4 files changed, 48 insertions(+), 31 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 3dcf1dc0..37e96fc8 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -11,12 +11,12 @@ CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA42392E2DF7219E000AF560 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA42392D2DF7219E000AF560 /* SwiftUINavigation */; }; - CA5E42502DE7C4D50069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E424F2DE7C4D50069E0F8 /* SwiftUINavigation */; }; CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99D72DF915D300934431 /* DependenciesTestSupport */; }; CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */; }; CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; + DC7082542E035FC500A66B7D /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DC7082532E035FC500A66B7D /* SwiftUINavigation */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8A2E02176700FB20F8 /* SharingGRDB */; }; DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8C2E02177200FB20F8 /* SharingGRDB */; }; @@ -212,7 +212,7 @@ files = ( CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */, - CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */, + DC7082542E035FC500A66B7D /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -434,8 +434,8 @@ name = Reminders; packageProductDependencies = ( CA14DBC82DA884C400E36852 /* CasePaths */, - CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */, DCD9AC8C2E02177200FB20F8 /* SharingGRDB */, + DC7082532E035FC500A66B7D /* SwiftUINavigation */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -520,8 +520,7 @@ DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */, DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, - CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, - DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */, + DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -1290,9 +1289,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */ = { + DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */ = { isa = XCLocalSwiftPackageReference; - relativePath = "../../sharing-grdb"; + relativePath = ..; }; /* End XCLocalSwiftPackageReference section */ @@ -1351,11 +1350,6 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = SwiftUINavigation; }; - CA5E424F2DE7C4D50069E0F8 /* SwiftUINavigation */ = { - isa = XCSwiftPackageProductDependency; - package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; - productName = SwiftUINavigation; - }; CA9F99D72DF915D300934431 /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; @@ -1381,6 +1375,11 @@ package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesMacros; }; + DC7082532E035FC500A66B7D /* SwiftUINavigation */ = { + isa = XCSwiftPackageProductDependency; + package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; + productName = SwiftUINavigation; + }; DCBE8A132D4842BF0071F499 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; @@ -1392,12 +1391,12 @@ }; DCD9AC8C2E02177200FB20F8 /* SharingGRDB */ = { isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; + package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */; productName = SharingGRDB; }; DCD9AC8E2E02177900FB20F8 /* SharingGRDB */ = { isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; + package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */; productName = SharingGRDB; }; DCF267382D48437300B680BE /* SwiftUINavigation */ = { diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 59541a19..bbd54c86 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5ded5ba49617fcf43253f921c393a9829acb4bd0620c1d273ad236940406de92", + "originHash" : "cfa986227a2051ca83eae9c181301c75e9fcd30d8f199a7ee1c7b269b548e192", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "6bd870099083ef269eeee8835808cb6233151654", - "version" : "0.7.0" + "branch" : "temp-triggers", + "revision" : "bcac7860fcef9ca1da97d8aa96737ed90465a01d" } }, { @@ -136,6 +136,15 @@ "version" : "601.0.1" } }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Package.resolved b/Package.resolved index ab5906bd..5bd02bf1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -136,6 +136,15 @@ "version" : "601.0.1" } }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0ecb37e3..e060bc5f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -150,7 +150,7 @@ public final class SyncEngine: Sendable { if !hasAttachedMetadatabase { try SQLQueryExpression( """ - ATTACH DATABASE \(bind: self.metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) + ATTACH DATABASE \(bind: metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) """ ) .execute(db) @@ -159,15 +159,15 @@ public final class SyncEngine: Sendable { db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) - try Metadata.createTriggers(tables: self.tables, db: db) + try Metadata.createTriggers(tables: tables, db: db) - for table in self.tables { - try table.createTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) + for table in tables { + try table.createTriggers(foreignKeysByTableName: foreignKeysByTableName, db: db) } } let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) - self.syncEngines.withValue { + syncEngines.withValue { $0 = SyncEngines( private: privateSyncEngine, shared: sharedSyncEngine @@ -182,7 +182,7 @@ public final class SyncEngine: Sendable { SELECT "name", "sql" FROM "sqlite_master" WHERE "type" = 'table' - AND "name" IN (\(self.tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) + AND "name" IN (\(tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) """, as: RecordType.self ) @@ -328,13 +328,13 @@ extension PrimaryKeyedTable { db: Database ) throws { let foreignKey = - foreignKeysByTableName[Self.tableName]?.count(where: \.notnull) == 1 - ? foreignKeysByTableName[Self.tableName]?.first(where: \.notnull) + foreignKeysByTableName[tableName]?.count(where: \.notnull) == 1 + ? foreignKeysByTableName[tableName]?.first(where: \.notnull) : nil try Metadata.createTriggers(for: Self.self, parentForeignKey: foreignKey, db: db) - let foreignKeys = foreignKeysByTableName[Self.tableName] ?? [] + let foreignKeys = foreignKeysByTableName[tableName] ?? [] for foreignKey in foreignKeys { try foreignKey.createTriggers(for: Self.self, db: db) } @@ -345,7 +345,7 @@ extension PrimaryKeyedTable { foreignKeysByTableName: [String: [ForeignKey]], db: Database ) throws { - let foreignKeys = foreignKeysByTableName[Self.tableName] ?? [] + let foreignKeys = foreignKeysByTableName[tableName] ?? [] for foreignKey in foreignKeys.reversed() { try foreignKey.dropTriggers(for: Self.self, db: db) } @@ -598,11 +598,11 @@ extension SyncEngine: CKSyncEngineDelegate { for record in modifications { if let share = record as? CKShare { await withErrorReporting { - try await self.cacheShare(share) + try await cacheShare(share) } } else { - self.upsertFromServerRecord(record) - self.refreshLastKnownServerRecord(record) + upsertFromServerRecord(record) + refreshLastKnownServerRecord(record) } if let shareReference = record.share, let shareRecord = try? await container.database(for: shareReference.recordID) @@ -708,7 +708,7 @@ extension SyncEngine: CKSyncEngineDelegate { guard let url = share.url else { return } - let metadata = try await self.container.shareMetadata(for: url, shouldFetchRootRecord: true) + let metadata = try await container.shareMetadata(for: url, shouldFetchRootRecord: true) guard let rootRecord = metadata.rootRecord else { return } From c05093c1af33df8e298703b2d808f8459223e2bf Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 18 Jun 2025 14:37:33 -0700 Subject: [PATCH 143/581] wip --- .../CloudKit/CloudKit+Helpers.swift | 4 +- .../CloudKit/CloudKit+StructuredQueries.swift | 12 ++- .../CloudKit/CloudKitSharing.swift | 10 +-- .../SharingGRDBCore/CloudKit/Logging.swift | 46 +++++------ .../CloudKit/MetadataTable.swift | 77 ++++++++++++++----- .../CloudKit/RecordTypeTable.swift | 16 +++- .../CloudKit/StateSerializationTable.swift | 25 ++++-- 7 files changed, 128 insertions(+), 62 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift index 03326417..3c84bdb9 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift @@ -18,7 +18,7 @@ extension CKContainer { func database(for recordID: CKRecord.ID) -> CKDatabase { recordID.zoneID.ownerName == CKCurrentUserDefaultName - ? privateCloudDatabase - : sharedCloudDatabase + ? privateCloudDatabase + : sharedCloudDatabase } } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index f874747c..a53c58a2 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -115,9 +115,11 @@ extension PrimaryKeyedTable where TableColumns.PrimaryKey == UUID { static func find(recordID: CKRecord.ID) -> Where { let recordName = UUID(uuidString: recordID.recordName) if recordName == nil { - reportIssue(""" + reportIssue( + """ 'recordName' ("\(recordID.recordName)") must be a UUID. - """) + """ + ) } return Self.where { $0.primaryKey.eq(recordName ?? UUID()) @@ -130,9 +132,11 @@ extension Metadata { init(record: CKRecord) { let recordName = UUID(uuidString: record.recordID.recordName) if recordName == nil { - reportIssue(""" + reportIssue( + """ 'recordName' ("\(record.recordID.recordName)") must be a UUID. - """) + """ + ) } self.init( recordType: record.recordType, diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 0baf2d96..abf45fac 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -43,10 +43,10 @@ extension SyncEngine { let rootRecord = metadata.lastKnownServerRecord - // 1) create record - // 2) (before sync) you share - // 3) create a CKRecord down below - // 4) a moment later, sync engine creates a record + // 1) create record + // 2) (before sync) you share + // 3) create a CKRecord down below + // 4) a moment later, sync engine creates a record ?? CKRecord( recordType: metadata.recordType, recordID: CKRecord.ID( @@ -138,7 +138,7 @@ extension SyncEngine { } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final class CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { let share: CKShare let didFinish: (Result) -> Void diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index d6d924bd..ede59ece 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -37,13 +37,13 @@ extension Logger { } case .fetchedDatabaseChanges(let event): let deletions = - event.deletions.isEmpty - ? "⚪️ No deletions" - : "✅ Zones deleted (\(event.deletions.count): " - + event.deletions - .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } - .sorted() - .joined(separator: ", ") + event.deletions.isEmpty + ? "⚪️ No deletions" + : "✅ Zones deleted (\(event.deletions.count): " + + event.deletions + .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } + .sorted() + .joined(separator: ", ") debug( """ \(prefix) fetchedDatabaseChanges @@ -59,8 +59,8 @@ extension Logger { .map { recordType in "\(recordType) (\(deletionsByRecordType[recordType]!.count))" } .joined(separator: ", ") let deletions = - event.deletions.isEmpty - ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(recordTypeDeletions)" + event.deletions.isEmpty + ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(recordTypeDeletions)" let modificationsByRecordType = Dictionary( grouping: event.modifications, @@ -70,9 +70,9 @@ extension Logger { .map { recordType in "\(recordType) (\(modificationsByRecordType[recordType]!.count))" } .joined(separator: ", ") let modifications = - event.modifications.isEmpty - ? "⚪️ No modifications" - : "✅ Records modified (\(event.modifications.count)): \(recordTypeModifications)" + event.modifications.isEmpty + ? "⚪️ No modifications" + : "✅ Records modified (\(event.modifications.count)): \(recordTypeModifications)" debug( """ @@ -87,26 +87,26 @@ extension Logger { .sorted() .joined(separator: ", ") let savedZones = - event.savedZones.isEmpty - ? "⚪️ No saved zones" : "✅ Saved zones (\(event.savedZones.count)): \(savedZoneNames)" + event.savedZones.isEmpty + ? "⚪️ No saved zones" : "✅ Saved zones (\(event.savedZones.count)): \(savedZoneNames)" let deletedZoneNames = event.deletedZoneIDs .map { $0.zoneName } .sorted() .joined(separator: ", ") let deletedZones = - event.deletedZoneIDs.isEmpty - ? "⚪️ No deleted zones" - : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" + event.deletedZoneIDs.isEmpty + ? "⚪️ No deleted zones" + : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" let failedZoneSaveNames = event.failedZoneSaves .map { $0.zone.zoneID.zoneName + ":" + $0.zone.zoneID.ownerName } .sorted() .joined(separator: ", ") let failedZoneSaves = - event.failedZoneSaves.isEmpty - ? "⚪️ No failed saved zones" - : "🛑 Failed zone saves (\(event.failedZoneSaves.count)): \(failedZoneSaveNames)" + event.failedZoneSaves.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(event.failedZoneSaves.count)): \(failedZoneSaveNames)" let failedZoneDeleteNames = event.failedZoneDeletes .keys @@ -114,9 +114,9 @@ extension Logger { .sorted() .joined(separator: ", ") let failedZoneDeletes = - event.failedZoneDeletes.isEmpty - ? "⚪️ No failed deleted zones" - : "🛑 Failed zone delete (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" + event.failedZoneDeletes.isEmpty + ? "⚪️ No failed deleted zones" + : "🛑 Failed zone delete (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" debug( """ diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift index 6bfe599a..eca6a921 100644 --- a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift @@ -13,19 +13,41 @@ public struct Metadata: Hashable, Sendable { public var share: CKShare? public var userModificationDate: Date? - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore + .PrimaryKeyedTableDefinition + { public typealias QueryValue = Metadata - public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + public let recordType = StructuredQueriesCore.TableColumn( + "recordType", + keyPath: \QueryValue.recordType + ) + public let recordName = StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + public let parentRecordName = StructuredQueriesCore.TableColumn( + "parentRecordName", + keyPath: \QueryValue.parentRecordName + ) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.DataRepresentation + >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn< + QueryValue, CKShare?.ShareDataRepresentation + >("share", keyPath: \QueryValue.share) + public let userModificationDate = StructuredQueriesCore.TableColumn( + "userModificationDate", + keyPath: \QueryValue.userModificationDate + ) public var primaryKey: StructuredQueriesCore.TableColumn { self.recordName } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] + [ + QueryValue.columns.recordType, QueryValue.columns.recordName, + QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, + QueryValue.columns.share, QueryValue.columns.userModificationDate, + ] } } @@ -39,14 +61,34 @@ public struct Metadata: Hashable, Sendable { public var userModificationDate: Date? public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Draft - public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + public let recordType = StructuredQueriesCore.TableColumn( + "recordType", + keyPath: \QueryValue.recordType + ) + public let recordName = StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + public let parentRecordName = StructuredQueriesCore.TableColumn( + "parentRecordName", + keyPath: \QueryValue.parentRecordName + ) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.DataRepresentation + >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn< + QueryValue, CKShare?.ShareDataRepresentation + >("share", keyPath: \QueryValue.share) + public let userModificationDate = StructuredQueriesCore.TableColumn( + "userModificationDate", + keyPath: \QueryValue.userModificationDate + ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] + [ + QueryValue.columns.recordType, QueryValue.columns.recordName, + QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, + QueryValue.columns.share, QueryValue.columns.userModificationDate, + ] } } public static let columns = TableColumns() @@ -100,7 +142,8 @@ public struct Metadata: Hashable, Sendable { } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Metadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public static let columns = TableColumns() public static let tableName = "sqlitedata_icloud_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { @@ -128,5 +171,3 @@ public struct Metadata: Hashable, Sendable { self.share = share } } - - diff --git a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift index dcaafa73..3925f3bd 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift @@ -12,9 +12,13 @@ extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.Primary { public typealias QueryValue = RecordType public let tableName = StructuredQueriesCore.TableColumn( - "tableName", keyPath: \QueryValue.tableName) + "tableName", + keyPath: \QueryValue.tableName + ) public let schema = StructuredQueriesCore.TableColumn( - "schema", keyPath: \QueryValue.schema) + "schema", + keyPath: \QueryValue.schema + ) public var primaryKey: StructuredQueriesCore.TableColumn { self.tableName } @@ -29,9 +33,13 @@ extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.Primary public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = RecordType.Draft public let tableName = StructuredQueriesCore.TableColumn( - "tableName", keyPath: \QueryValue.tableName) + "tableName", + keyPath: \QueryValue.tableName + ) public let schema = StructuredQueriesCore.TableColumn( - "schema", keyPath: \QueryValue.schema) + "schema", + keyPath: \QueryValue.schema + ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.tableName, QueryValue.columns.schema] } diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift index 80c1a9e7..52b48eb4 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift @@ -13,11 +13,19 @@ package struct StateSerialization { extension CKDatabase.Scope: @retroactive QueryBindable { } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore + .PrimaryKeyedTableDefinition + { public typealias QueryValue = StateSerialization - public let scope = StructuredQueriesCore.TableColumn("scope", keyPath: \QueryValue.scope) - public let data = StructuredQueriesCore.TableColumn("data", keyPath: \QueryValue.data) + public let scope = StructuredQueriesCore.TableColumn( + "scope", + keyPath: \QueryValue.scope + ) + public let data = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation + >("data", keyPath: \QueryValue.data) public var primaryKey: StructuredQueriesCore.TableColumn { self.scope } @@ -31,8 +39,13 @@ extension CKDatabase.Scope: @retroactive QueryBindable { package var data: CKSyncEngine.State.Serialization public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = StateSerialization.Draft - public let scope = StructuredQueriesCore.TableColumn("scope", keyPath: \QueryValue.scope) - public let data = StructuredQueriesCore.TableColumn("data", keyPath: \QueryValue.data) + public let scope = StructuredQueriesCore.TableColumn( + "scope", + keyPath: \QueryValue.scope + ) + public let data = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation + >("data", keyPath: \QueryValue.data) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.scope, QueryValue.columns.data] } From f883c66b73c4953ec07d6a7f23dcbf17df286f5e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 18 Jun 2025 15:20:37 -0700 Subject: [PATCH 144/581] wip --- Examples/Reminders/RemindersListForm.swift | 22 ++++++------- .../SharingGRDBCore/CloudKit/Metadata.swift | 3 -- .../CloudKitTests/TriggerTests.swift | 32 ++++--------------- 3 files changed, 18 insertions(+), 39 deletions(-) diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 9245a1c9..ab2234df 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -86,17 +86,17 @@ struct RemindersListForm: View { reportIssue("No 'remindersListID'") return } - try RemindersListAsset.insert { - RemindersListAsset.Draft( - coverImage: coverImageData, - remindersListID: remindersListID - ) - } onConflict: { - $0.remindersListID - } doUpdate: { - $0.coverImage = coverImageData - } - .execute(db) + // try RemindersListAsset.insert { + // RemindersListAsset.Draft( + // coverImage: coverImageData, + // remindersListID: remindersListID + // ) + // } onConflict: { + // $0.remindersListID + // } doUpdate: { + // $0.coverImage = coverImageData + // } + // .execute(db) } } } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 427c6c00..1b8d7d02 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -74,13 +74,10 @@ extension Metadata { "new".\(quote: T.columns.primaryKey.name), \(raw: foreignKey) AS "foreignKey", datetime('subsec') - FROM (SELECT 1) - LEFT JOIN \(Metadata.self) ON \(Metadata.recordName) = "foreignKey" ON CONFLICT(\(quote: Metadata.recordName.name)) DO UPDATE SET \(quote: Metadata.recordType.name) = "excluded".\(quote: Metadata.recordType.name), \(quote: Metadata.parentRecordName.name) = "excluded".\(quote: Metadata.parentRecordName.name), - \(quote: Metadata.recordType.name) = "excluded".\(quote: Metadata.recordType.name), \(quote: Metadata.userModificationDate.name) = "excluded".\(quote: Metadata.userModificationDate.name) """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 1e4e6d7c..fbf9c8b5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -32,7 +32,7 @@ extension BaseCloudKitTests { """, [2]: """ CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata@SharingGRDBCore/Metadata.swift:28:69" - AFTER DELETE ON "sqlitedata_icloud_metadata" + AFTER DELETE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT sqlitedata_icloud_didDelete("old"."recordName"); END @@ -52,13 +52,10 @@ extension BaseCloudKitTests { "new"."id", "new"."remindersListID" AS "foreignKey", datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; END """, @@ -77,19 +74,16 @@ extension BaseCloudKitTests { "new"."id", "new"."remindersListID" AS "foreignKey", datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [5]: """ - CREATE TRIGGER "after_delete_on_reminders@SharingGRDBCore/Metadata.swift:148:27" - AFTER DELETE ON "reminders" + CREATE TRIGGER "after_delete_on_reminders@SharingGRDBCore/Metadata.swift:145:27" + AFTER DELETE ON "reminders" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id"); @@ -163,13 +157,10 @@ extension BaseCloudKitTests { "new"."id", NULL AS "foreignKey", datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; END """, @@ -188,19 +179,16 @@ extension BaseCloudKitTests { "new"."id", NULL AS "foreignKey", datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [14]: """ - CREATE TRIGGER "after_delete_on_remindersLists@SharingGRDBCore/Metadata.swift:148:27" - AFTER DELETE ON "remindersLists" + CREATE TRIGGER "after_delete_on_remindersLists@SharingGRDBCore/Metadata.swift:145:27" + AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id"); @@ -221,13 +209,10 @@ extension BaseCloudKitTests { "new"."id", NULL AS "foreignKey", datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; END """, @@ -246,19 +231,16 @@ extension BaseCloudKitTests { "new"."id", NULL AS "foreignKey", datetime('subsec') - FROM (SELECT 1) - LEFT JOIN "sqlitedata_icloud_metadata" ON "sqlitedata_icloud_metadata"."recordName" = "foreignKey" ON CONFLICT("recordName") DO UPDATE SET "recordType" = "excluded"."recordType", "parentRecordName" = "excluded"."parentRecordName", - "recordType" = "excluded"."recordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [17]: """ - CREATE TRIGGER "after_delete_on_users@SharingGRDBCore/Metadata.swift:148:27" - AFTER DELETE ON "users" + CREATE TRIGGER "after_delete_on_users@SharingGRDBCore/Metadata.swift:145:27" + AFTER DELETE ON "users" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id"); From 03ef7293cbc2ac185971363f03832b077dae9bcc Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 18 Jun 2025 15:34:07 -0700 Subject: [PATCH 145/581] wip --- Sources/SharingGRDBCore/CloudKit/Metadata.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index 1b8d7d02..b9201586 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -4,6 +4,7 @@ import StructuredQueriesCore @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Metadata { fileprivate static let afterInsertTrigger = createTemporaryTrigger( + "after_insert_on_sqlitedata_icloud_metadata", ifNotExists: true, after: .insert { SQLQueryExpression( @@ -15,6 +16,7 @@ extension Metadata { ) fileprivate static let afterUpdateTrigger = createTemporaryTrigger( + "after_update_on_sqlitedata_icloud_metadata", ifNotExists: true, after: .update { _, new in SQLQueryExpression( @@ -26,6 +28,7 @@ extension Metadata { ) fileprivate static let afterDeleteTrigger = createTemporaryTrigger( + "after_insert_on_sqlitedata_icloud_metadata", ifNotExists: true, after: .delete { SQLQueryExpression( @@ -49,9 +52,9 @@ extension Metadata { tables: [any PrimaryKeyedTable.Type], db: Database ) throws { - try afterInsertTrigger.drop().execute(db) - try afterUpdateTrigger.drop().execute(db) try afterDeleteTrigger.drop().execute(db) + try afterUpdateTrigger.drop().execute(db) + try afterInsertTrigger.drop().execute(db) } static func createTriggers>( @@ -83,7 +86,7 @@ extension Metadata { try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS \(Self.insertTriggerName(for: T.self)) + CREATE TEMPORARY TRIGGER IF NOT EXISTS \(insertTriggerName(for: T.self)) AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN \(upsert); END @@ -92,7 +95,7 @@ extension Metadata { .execute(db) try SQLQueryExpression( """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS \(Self.updateTriggerName(for: T.self)) + CREATE TEMPORARY TRIGGER IF NOT EXISTS \(updateTriggerName(for: T.self)) AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN \(upsert); END @@ -143,6 +146,7 @@ extension Metadata { extension PrimaryKeyedTable { fileprivate static var createDeleteTrigger: TemporaryTrigger { createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)", ifNotExists: true, after: .delete { old in Metadata From 4e9f9fa810a22d93699297b75a71d323a29de8c2 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 18 Jun 2025 15:37:21 -0700 Subject: [PATCH 146/581] wip --- Sources/SharingGRDBCore/CloudKit/Metadata.swift | 2 +- .../CloudKitTests/TriggerTests.swift | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index b9201586..f4d1ea38 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -28,7 +28,7 @@ extension Metadata { ) fileprivate static let afterDeleteTrigger = createTemporaryTrigger( - "after_insert_on_sqlitedata_icloud_metadata", + "after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, after: .delete { SQLQueryExpression( diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index fbf9c8b5..1a19a455 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -17,21 +17,21 @@ extension BaseCloudKitTests { #""" [ [0]: """ - CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata@SharingGRDBCore/Metadata.swift:6:69" + CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName"); END """, [1]: """ - CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata@SharingGRDBCore/Metadata.swift:17:69" + CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName"); END """, [2]: """ - CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata@SharingGRDBCore/Metadata.swift:28:69" + CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" AFTER DELETE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN SELECT sqlitedata_icloud_didDelete("old"."recordName"); @@ -82,7 +82,7 @@ extension BaseCloudKitTests { END """, [5]: """ - CREATE TRIGGER "after_delete_on_reminders@SharingGRDBCore/Metadata.swift:145:27" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" @@ -187,7 +187,7 @@ extension BaseCloudKitTests { END """, [14]: """ - CREATE TRIGGER "after_delete_on_remindersLists@SharingGRDBCore/Metadata.swift:145:27" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" @@ -239,7 +239,7 @@ extension BaseCloudKitTests { END """, [17]: """ - CREATE TRIGGER "after_delete_on_users@SharingGRDBCore/Metadata.swift:145:27" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_users" AFTER DELETE ON "users" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" From 7f950abcd11ff03704b60cf881240f640794bb3e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 19 Jun 2025 12:54:12 -0700 Subject: [PATCH 147/581] wip --- .../SharingGRDBCore/CloudKit/Metadata.swift | 4 ++-- .../CloudKit/MetadataTable.swift | 14 +++++++------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 18 ++++++++++-------- .../CloudKitTests/MetadataTests.swift | 4 ++-- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/Metadata.swift index f4d1ea38..d6110090 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadata.swift @@ -129,7 +129,7 @@ extension Metadata { for _: T.Type ) -> SQLQueryExpression { SQLQueryExpression( - #""\#(raw: .sqliteDataCloudKitSchemaName)_\#(raw: T.tableName)_metadataInserts""# + "\(quote: "\(String.sqliteDataCloudKitSchemaName)_\(T.tableName)_metadataInserts")" ) } @@ -137,7 +137,7 @@ extension Metadata { for _: T.Type ) -> SQLQueryExpression { SQLQueryExpression( - #""\#(raw: .sqliteDataCloudKitSchemaName)_\#(raw: T.tableName)_metadataUpdates""# + "\(quote: "\(String.sqliteDataCloudKitSchemaName)_\(T.tableName)_metadataUpdates")" ) } } diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift index eca6a921..7367bf0a 100644 --- a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift @@ -6,7 +6,7 @@ public struct Metadata: Hashable, Sendable { public var recordType: String // @Column(primaryKey: true) public var recordName: UUID - public var parentRecordName: String? + public var parentRecordName: UUID? // @Column(as: CKRecord?.DataRepresentation.self) public var lastKnownServerRecord: CKRecord? // @Column(as: CKShare?.ShareDataRepresentation.self) @@ -25,7 +25,7 @@ public struct Metadata: Hashable, Sendable { "recordName", keyPath: \QueryValue.recordName ) - public let parentRecordName = StructuredQueriesCore.TableColumn( + public let parentRecordName = StructuredQueriesCore.TableColumn( "parentRecordName", keyPath: \QueryValue.parentRecordName ) @@ -55,7 +55,7 @@ public struct Metadata: Hashable, Sendable { public typealias PrimaryTable = Metadata public var recordType: String public var recordName: UUID? - public var parentRecordName: String? + public var parentRecordName: UUID? public var lastKnownServerRecord: CKRecord? public var share: CKShare? public var userModificationDate: Date? @@ -69,7 +69,7 @@ public struct Metadata: Hashable, Sendable { "recordName", keyPath: \QueryValue.recordName ) - public let parentRecordName = StructuredQueriesCore.TableColumn( + public let parentRecordName = StructuredQueriesCore.TableColumn( "parentRecordName", keyPath: \QueryValue.parentRecordName ) @@ -98,7 +98,7 @@ public struct Metadata: Hashable, Sendable { public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) self.recordName = try decoder.decode(UUID.self) - self.parentRecordName = try decoder.decode(String.self) + self.parentRecordName = try decoder.decode(UUID.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) @@ -127,7 +127,7 @@ public struct Metadata: Hashable, Sendable { public init( recordType: String, recordName: UUID? = nil, - parentRecordName: String? = nil, + parentRecordName: UUID? = nil, lastKnownServerRecord: CKRecord? = nil, share: CKShare? = nil, userModificationDate: Date? = nil @@ -149,7 +149,7 @@ extension Metadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKe public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) let recordName = try decoder.decode(UUID.self) - self.parentRecordName = try decoder.decode(String.self) + self.parentRecordName = try decoder.decode(UUID.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index e060bc5f..28912e06 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -492,7 +492,7 @@ extension SyncEngine: CKSyncEngineDelegate { record.parent = metadata.parentRecordName.map { parentRecordName in CKRecord.Reference( recordID: CKRecord.ID( - recordName: parentRecordName, + recordName: parentRecordName.uuidString.lowercased(), zoneID: record.recordID.zoneID ), action: .none @@ -740,13 +740,6 @@ extension SyncEngine: CKSyncEngineDelegate { private func upsertFromServerRecord(_ record: CKRecord) { $isUpdatingWithServerRecord.withValue(true) { withErrorReporting(.sqliteDataCloudKitFailure) { - let userModificationDate = - try metadatabase.read { db in - try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne( - db - ) - } - ?? nil guard let table = tablesByName[record.recordType] else { // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? @@ -759,6 +752,13 @@ extension SyncEngine: CKSyncEngineDelegate { ) return } + let userModificationDate = + try metadatabase.read { db in + try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne( + db + ) + } + ?? nil guard let userModificationDate, userModificationDate > record.userModificationDate ?? .distantPast @@ -805,6 +805,8 @@ extension SyncEngine: CKSyncEngineDelegate { } .joined(separator: ",") ) + // TODO: Append more ON CONFLICT clauses for each unique constraint? + // TODO: Use WHERE to scope the update? try database.write { db in try SQLQueryExpression(query).execute(db) try Metadata diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 0cd12253..4930980d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -30,7 +30,7 @@ extension BaseCloudKitTests { .find(UUID(3)) .fetchOne(db) ) - #expect(reminderMetadata.parentRecordName == UUID(1).uuidString) + #expect(reminderMetadata.parentRecordName == UUID(1)) } try database.write { db in @@ -44,7 +44,7 @@ extension BaseCloudKitTests { .find(UUID(3)) .fetchOne(db) ) - #expect(reminderMetadata.parentRecordName == UUID(2).uuidString) + #expect(reminderMetadata.parentRecordName == UUID(2)) } privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(CKRecord.ID(UUID(3))), From b17e8e368a2e95cfacf191e0d69c9fd01b682251 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 19 Jun 2025 12:58:27 -0700 Subject: [PATCH 148/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../CloudKit/CloudKitSharing.swift | 4 +-- .../CloudKit/MetadataTable.swift | 12 +++---- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 32 +++++++++---------- .../{Metadata.swift => SyncMetadata.swift} | 22 ++++++------- .../CloudKitTests/CloudKitTests.swift | 14 ++++---- .../CloudKitTests/MetadataTests.swift | 4 +-- 7 files changed, 45 insertions(+), 45 deletions(-) rename Sources/SharingGRDBCore/CloudKit/{Metadata.swift => SyncMetadata.swift} (86%) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index a53c58a2..8192b8e2 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -128,7 +128,7 @@ extension PrimaryKeyedTable where TableColumns.PrimaryKey == UUID { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Metadata { +extension SyncMetadata { init(record: CKRecord) { let recordName = UUID(uuidString: record.recordID.recordName) if recordName == nil { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index abf45fac..17c0bf75 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -31,7 +31,7 @@ extension SyncEngine { let recordName = record[keyPath: T.columns.primaryKey.keyPath] let metadata = try await metadatabase.read { db in - try Metadata + try SyncMetadata .find(recordName) .fetchOne(db) } ?? nil @@ -79,7 +79,7 @@ extension SyncEngine { deleting: [] ) try await database.write { db in - try Metadata + try SyncMetadata .find(recordName) .update { $0.share = sharedRecord } .execute(db) diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift index 7367bf0a..57d0d23c 100644 --- a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift @@ -2,7 +2,7 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") -public struct Metadata: Hashable, Sendable { +public struct SyncMetadata: Hashable, Sendable { public var recordType: String // @Column(primaryKey: true) public var recordName: UUID @@ -16,7 +16,7 @@ public struct Metadata: Hashable, Sendable { public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore .PrimaryKeyedTableDefinition { - public typealias QueryValue = Metadata + public typealias QueryValue = SyncMetadata public let recordType = StructuredQueriesCore.TableColumn( "recordType", keyPath: \QueryValue.recordType @@ -52,7 +52,7 @@ public struct Metadata: Hashable, Sendable { } public struct Draft: StructuredQueriesCore.TableDraft { - public typealias PrimaryTable = Metadata + public typealias PrimaryTable = SyncMetadata public var recordType: String public var recordName: UUID? public var parentRecordName: UUID? @@ -93,7 +93,7 @@ public struct Metadata: Hashable, Sendable { } public static let columns = TableColumns() - public static let tableName = Metadata.tableName + public static let tableName = SyncMetadata.tableName public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) @@ -116,7 +116,7 @@ public struct Metadata: Hashable, Sendable { self.share = share } - public init(_ other: Metadata) { + public init(_ other: SyncMetadata) { self.recordType = other.recordType self.recordName = other.recordName self.parentRecordName = other.parentRecordName @@ -143,7 +143,7 @@ public struct Metadata: Hashable, Sendable { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Metadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { +extension SyncMetadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public static let columns = TableColumns() public static let tableName = "sqlitedata_icloud_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 28912e06..0e2ccd25 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -159,7 +159,7 @@ public final class SyncEngine: Sendable { db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) - try Metadata.createTriggers(tables: tables, db: db) + try SyncMetadata.createTriggers(tables: tables, db: db) for table in tables { try table.createTriggers(foreignKeysByTableName: foreignKeysByTableName, db: db) @@ -231,14 +231,14 @@ public final class SyncEngine: Sendable { for table in self.tables { try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } - try Metadata.dropTriggers(tables: self.tables, db: db) + try SyncMetadata.dropTriggers(tables: self.tables, db: db) db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .isUpdatingWithServerRecord) } try await database.write { db in // TODO: Do an `.erase()` + re-migrate - try Metadata.delete().execute(db) + try SyncMetadata.delete().execute(db) try RecordType.delete().execute(db) try StateSerialization.delete().execute(db) } @@ -312,7 +312,7 @@ public final class SyncEngine: Sendable { let metadata = withErrorReporting { try metadatabase.read { db in - try Metadata + try SyncMetadata .find(UUID(uuidString: recordName)!) .fetchOne(db) } @@ -332,7 +332,7 @@ extension PrimaryKeyedTable { ? foreignKeysByTableName[tableName]?.first(where: \.notnull) : nil - try Metadata.createTriggers(for: Self.self, parentForeignKey: foreignKey, db: db) + try SyncMetadata.createTriggers(for: Self.self, parentForeignKey: foreignKey, db: db) let foreignKeys = foreignKeysByTableName[tableName] ?? [] for foreignKey in foreignKeys { @@ -349,7 +349,7 @@ extension PrimaryKeyedTable { for foreignKey in foreignKeys.reversed() { try foreignKey.dropTriggers(for: Self.self, db: db) } - try Metadata.dropTriggers(for: Self.self, db: db) + try SyncMetadata.dropTriggers(for: Self.self, db: db) } } @@ -666,7 +666,7 @@ extension SyncEngine: CKSyncEngineDelegate { withErrorReporting { try $isUpdatingWithServerRecord.withValue(true) { try database.write { db in - try Metadata + try SyncMetadata .find(recordID: failedRecord.recordID) .update { $0.lastKnownServerRecord = nil } .execute(db) @@ -714,7 +714,7 @@ extension SyncEngine: CKSyncEngineDelegate { else { return } try await database.write { db in - try Metadata + try SyncMetadata .find(recordID: rootRecord.recordID) .update { $0.share = share } .execute(db) @@ -725,13 +725,13 @@ extension SyncEngine: CKSyncEngineDelegate { // TODO: more efficient way to do this? try database.write { db in let metadata = - try Metadata + try SyncMetadata .where { $0.share.isNot(nil) } .fetchAll(db) .first(where: { $0.share?.recordID == recordID }) ?? nil guard let metadata else { return } - try Metadata.find(metadata.recordName) + try SyncMetadata.find(metadata.recordName) .update { $0.share = nil } .execute(db) } @@ -754,7 +754,7 @@ extension SyncEngine: CKSyncEngineDelegate { } let userModificationDate = try metadatabase.read { db in - try Metadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne( + try SyncMetadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne( db ) } @@ -809,9 +809,9 @@ extension SyncEngine: CKSyncEngineDelegate { // TODO: Use WHERE to scope the update? try database.write { db in try SQLQueryExpression(query).execute(db) - try Metadata + try SyncMetadata .insert { - Metadata(record: record) + SyncMetadata(record: record) } onConflictDoUpdate: { $0.lastKnownServerRecord = record $0.userModificationDate = record.userModificationDate @@ -831,7 +831,7 @@ extension SyncEngine: CKSyncEngineDelegate { func updateLastKnownServerRecord() { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in - try Metadata + try SyncMetadata .find(recordID: record.recordID) .update { $0.lastKnownServerRecord = record } .execute(db) @@ -849,10 +849,10 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func metadataFor(recordID: CKRecord.ID) -> Metadata? { + private func metadataFor(recordID: CKRecord.ID) -> SyncMetadata? { withErrorReporting(.sqliteDataCloudKitFailure) { try metadatabase.read { db in - try Metadata.find(recordID: recordID).fetchOne(db) + try SyncMetadata.find(recordID: recordID).fetchOne(db) } } ?? nil diff --git a/Sources/SharingGRDBCore/CloudKit/Metadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift similarity index 86% rename from Sources/SharingGRDBCore/CloudKit/Metadata.swift rename to Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index d6110090..8d0b8515 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -2,7 +2,7 @@ import CloudKit import StructuredQueriesCore @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Metadata { +extension SyncMetadata { fileprivate static let afterInsertTrigger = createTemporaryTrigger( "after_insert_on_sqlitedata_icloud_metadata", ifNotExists: true, @@ -65,23 +65,23 @@ extension Metadata { let foreignKey = (parentForeignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" let upsert: QueryFragment = """ - INSERT INTO \(Metadata.self) + INSERT INTO \(Self.self) ( - \(quote: Metadata.recordType.name), - \(quote: Metadata.recordName.name), - \(quote: Metadata.parentRecordName.name), - \(quote: Metadata.userModificationDate.name) + \(quote: recordType.name), + \(quote: recordName.name), + \(quote: parentRecordName.name), + \(quote: userModificationDate.name) ) SELECT \(quote: T.tableName, delimiter: .text), "new".\(quote: T.columns.primaryKey.name), \(raw: foreignKey) AS "foreignKey", datetime('subsec') - ON CONFLICT(\(quote: Metadata.recordName.name)) DO UPDATE + ON CONFLICT(\(quote: SyncMetadata.recordName.name)) DO UPDATE SET - \(quote: Metadata.recordType.name) = "excluded".\(quote: Metadata.recordType.name), - \(quote: Metadata.parentRecordName.name) = "excluded".\(quote: Metadata.parentRecordName.name), - \(quote: Metadata.userModificationDate.name) = "excluded".\(quote: Metadata.userModificationDate.name) + \(quote: recordType.name) = "excluded".\(quote: recordType.name), + \(quote: parentRecordName.name) = "excluded".\(quote: parentRecordName.name), + \(quote: userModificationDate.name) = "excluded".\(quote: userModificationDate.name) """ try SQLQueryExpression( @@ -149,7 +149,7 @@ extension PrimaryKeyedTable { "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)", ifNotExists: true, after: .delete { old in - Metadata + SyncMetadata .where { $0.recordName.eq(old.primaryKey) } .delete() } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index e070c008..fab1a709 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -62,7 +62,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDown() async throws { _ = try await database.write { db in - try Metadata.count().fetchOne(db) ?? 0 + try SyncMetadata.count().fetchOne(db) ?? 0 } try await syncEngine.tearDownSyncEngine() // await #expect(throws: DatabaseError.self) { @@ -104,7 +104,7 @@ extension BaseCloudKitTests { let metadata = try await database.write { db in - try Metadata.find(UUID(1)).fetchOne(db) + try SyncMetadata.find(UUID(1)).fetchOne(db) } #expect(metadata != nil) } @@ -195,7 +195,7 @@ extension BaseCloudKitTests { ) let userModificationDate = try #require( try await database.write { db in - try Metadata.find(UUID(1)).select(\.userModificationDate).fetchOne(db) ?? nil + try SyncMetadata.find(UUID(1)).select(\.userModificationDate).fetchOne(db) ?? nil } ) @@ -212,7 +212,7 @@ extension BaseCloudKitTests { let metadata = try #require( try await database.write { db in - try Metadata.find(UUID(1)).fetchOne(db) + try SyncMetadata.find(UUID(1)).fetchOne(db) } ) // TODO: Control dates in SQLite in order to get consistent passing on float comparison @@ -235,7 +235,7 @@ extension BaseCloudKitTests { ) let userModificationDate = try #require( try await database.write { db in - try Metadata + try SyncMetadata .find(UUID(1)) .select(\.userModificationDate) .fetchOne(db) ?? nil @@ -255,7 +255,7 @@ extension BaseCloudKitTests { let metadata = try #require( try await database.write { db in - try Metadata.find(UUID(1)).fetchOne(db) + try SyncMetadata.find(UUID(1)).fetchOne(db) } ) #expect(metadata.userModificationDate == userModificationDate) @@ -285,7 +285,7 @@ extension BaseCloudKitTests { == 0 ) let metadata = try await database.write { db in - try Metadata.find(UUID(1)).fetchOne(db) + try SyncMetadata.find(UUID(1)).fetchOne(db) } #expect(metadata == nil) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 4930980d..1308836d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -26,7 +26,7 @@ extension BaseCloudKitTests { try database.write { db in let reminderMetadata = try #require( - try Metadata + try SyncMetadata .find(UUID(3)) .fetchOne(db) ) @@ -40,7 +40,7 @@ extension BaseCloudKitTests { } try database.write { db in let reminderMetadata = try #require( - try Metadata + try SyncMetadata .find(UUID(3)) .fetchOne(db) ) From ab9ebcc6fe8695b754a9499a1cde45255ceb0bb3 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 20 Jun 2025 11:33:30 -0700 Subject: [PATCH 149/581] wip --- Package.resolved | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 17 +- .../CloudKit/SyncMetadata.swift | 158 ------------------ .../SharingGRDBCore/CloudKit/Triggers.swift | 126 ++++++++++++++ .../CloudKitTests/TriggerTests.swift | 156 +++++------------ 5 files changed, 185 insertions(+), 274 deletions(-) delete mode 100644 Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/Triggers.swift diff --git a/Package.resolved b/Package.resolved index 5bd02bf1..55fd1767 100644 --- a/Package.resolved +++ b/Package.resolved @@ -124,7 +124,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "temp-triggers", - "revision" : "bcac7860fcef9ca1da97d8aa96737ed90465a01d" + "revision" : "365ce8f75dd119bcb4788481fe0590c4c4aee63b" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0e2ccd25..c2a8d474 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -159,7 +159,9 @@ public final class SyncEngine: Sendable { db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) - try SyncMetadata.createTriggers(tables: tables, db: db) + for trigger in SyncMetadata.triggers { + try trigger.execute(db) + } for table in tables { try table.createTriggers(foreignKeysByTableName: foreignKeysByTableName, db: db) @@ -231,7 +233,9 @@ public final class SyncEngine: Sendable { for table in self.tables { try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } - try SyncMetadata.dropTriggers(tables: self.tables, db: db) + for trigger in SyncMetadata.triggers.reversed() { + try trigger.drop().execute(db) + } db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .isUpdatingWithServerRecord) @@ -332,7 +336,9 @@ extension PrimaryKeyedTable { ? foreignKeysByTableName[tableName]?.first(where: \.notnull) : nil - try SyncMetadata.createTriggers(for: Self.self, parentForeignKey: foreignKey, db: db) + for trigger in triggers(foreignKey: foreignKey) { + try trigger.execute(db) + } let foreignKeys = foreignKeysByTableName[tableName] ?? [] for foreignKey in foreignKeys { @@ -349,7 +355,10 @@ extension PrimaryKeyedTable { for foreignKey in foreignKeys.reversed() { try foreignKey.dropTriggers(for: Self.self, db: db) } - try SyncMetadata.dropTriggers(for: Self.self, db: db) + + for trigger in triggers(foreignKey: nil).reversed() { + try trigger.drop().execute(db) + } } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift deleted file mode 100644 index 8d0b8515..00000000 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ /dev/null @@ -1,158 +0,0 @@ -import CloudKit -import StructuredQueriesCore - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - fileprivate static let afterInsertTrigger = createTemporaryTrigger( - "after_insert_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .insert { - SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\($0.recordName))" - ) - } when: { _ in - SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - ) - - fileprivate static let afterUpdateTrigger = createTemporaryTrigger( - "after_update_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .update { _, new in - SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(new.recordName))" - ) - } when: { _, _ in - SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - ) - - fileprivate static let afterDeleteTrigger = createTemporaryTrigger( - "after_delete_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .delete { - SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didDelete(\($0.recordName))" - ) - } when: { _ in - SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - ) - - static func createTriggers( - tables: [any PrimaryKeyedTable.Type], - db: Database - ) throws { - try afterInsertTrigger.execute(db) - try afterUpdateTrigger.execute(db) - try afterDeleteTrigger.execute(db) - } - - static func dropTriggers( - tables: [any PrimaryKeyedTable.Type], - db: Database - ) throws { - try afterDeleteTrigger.drop().execute(db) - try afterUpdateTrigger.drop().execute(db) - try afterInsertTrigger.drop().execute(db) - } - - static func createTriggers>( - for _: T.Type, - parentForeignKey: ForeignKey?, - db: Database - ) throws { - let foreignKey = (parentForeignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" - - let upsert: QueryFragment = """ - INSERT INTO \(Self.self) - ( - \(quote: recordType.name), - \(quote: recordName.name), - \(quote: parentRecordName.name), - \(quote: userModificationDate.name) - ) - SELECT - \(quote: T.tableName, delimiter: .text), - "new".\(quote: T.columns.primaryKey.name), - \(raw: foreignKey) AS "foreignKey", - datetime('subsec') - ON CONFLICT(\(quote: SyncMetadata.recordName.name)) DO UPDATE - SET - \(quote: recordType.name) = "excluded".\(quote: recordType.name), - \(quote: parentRecordName.name) = "excluded".\(quote: parentRecordName.name), - \(quote: userModificationDate.name) = "excluded".\(quote: userModificationDate.name) - """ - - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS \(insertTriggerName(for: T.self)) - AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN - \(upsert); - END - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS \(updateTriggerName(for: T.self)) - AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN - \(upsert); - END - """ - ) - .execute(db) - - try T.createDeleteTrigger.execute(db) - } - - static func dropTriggers>( - for _: T.Type, - db: Database - ) throws { - try T.createDeleteTrigger.drop().execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER \(updateTriggerName(for: T.self)) - """ - ) - .execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER \(insertTriggerName(for: T.self)) - """ - ) - .execute(db) - } - - private static func insertTriggerName( - for _: T.Type - ) -> SQLQueryExpression { - SQLQueryExpression( - "\(quote: "\(String.sqliteDataCloudKitSchemaName)_\(T.tableName)_metadataInserts")" - ) - } - - private static func updateTriggerName( - for _: T.Type - ) -> SQLQueryExpression { - SQLQueryExpression( - "\(quote: "\(String.sqliteDataCloudKitSchemaName)_\(T.tableName)_metadataUpdates")" - ) - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTable { - fileprivate static var createDeleteTrigger: TemporaryTrigger { - createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)", - ifNotExists: true, - after: .delete { old in - SyncMetadata - .where { $0.recordName.eq(old.primaryKey) } - .delete() - } - ) - } -} diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift new file mode 100644 index 00000000..d2ea066c --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -0,0 +1,126 @@ +import Foundation + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension PrimaryKeyedTable { + static func triggers(foreignKey: ForeignKey?) -> [TemporaryTrigger] { + [ + afterInsert(foreignKey: foreignKey), + afterUpdate(foreignKey: foreignKey), + afterDelete, + ] + } + + fileprivate static func afterInsert(foreignKey: ForeignKey?) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", + after: .insert { new in SyncMetadata.insert(new: new, foreignKey: foreignKey) } + ) + } + + fileprivate static func afterUpdate(foreignKey: ForeignKey?) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", + after: .update { _, new in SyncMetadata.insert(new: new, foreignKey: foreignKey) } + ) + } + + fileprivate static var afterDelete: TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)", + ifNotExists: true, + after: .delete { old in + SyncMetadata + .where { $0.recordName.eq(old.primaryKey) } + .delete() + } + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata { + fileprivate static func insert>( + new: TemporaryTrigger.Operation.New, + foreignKey: ForeignKey?, + ) -> some StructuredQueriesCore.Statement { + let foreignKey = (foreignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" + return insert { + ($0.recordType, $0.recordName, $0.parentRecordName, $0.userModificationDate) + } select: { + Values( + T.tableName, + new.primaryKey, + SQLQueryExpression(#"\#(raw: foreignKey) AS "foreignKey""#), + .datetime("subsec") + ) + } onConflict: { + $0.recordName + } doUpdate: { + $0.recordName = SQLQueryExpression(#""excluded"."recordName""#) + $0.parentRecordName = SQLQueryExpression(#""excluded"."parentRecordName""#) + $0.userModificationDate = SQLQueryExpression(#""excluded"."userModificationDate""#) + } + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata { + static var triggers: [TemporaryTrigger] { + [ + afterInsertTrigger, + afterUpdateTrigger, + afterDeleteTrigger, + ] + } + + fileprivate static let afterInsertTrigger = createTemporaryTrigger( + "after_insert_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .insert { + Values(.didUpdate($0.recordName)) + } when: { _ in + !isUpdatingWithServerRecord() + } + ) + + fileprivate static let afterUpdateTrigger = createTemporaryTrigger( + "after_update_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update { _, new in + Values(.didUpdate(new.recordName)) + } when: { _, _ in + !isUpdatingWithServerRecord() + } + ) + + fileprivate static let afterDeleteTrigger = createTemporaryTrigger( + "after_delete_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .delete { + Values(.didDelete($0.recordName)) + } when: { _ in + !isUpdatingWithServerRecord() + } + ) +} + +extension QueryExpression where Self == SQLQueryExpression<()> { + fileprivate static func didUpdate(_ expression: some QueryExpression) -> Self { + Self("\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(expression))") + } + + fileprivate static func didDelete(_ expression: some QueryExpression) -> Self { + Self("\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(expression))") + } +} + +private func isUpdatingWithServerRecord() -> SQLQueryExpression { + SQLQueryExpression("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") +} + +extension QueryExpression { + fileprivate static func datetime>(_ string: String) -> Self + where Self == SQLQueryExpression { + Self("datetime(\(quote: string, delimiter: .text))") + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 1a19a455..bbabefad 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -19,66 +19,44 @@ extension BaseCloudKitTests { [0]: """ CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN + FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName"); END """, [1]: """ CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN + FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName"); END """, [2]: """ CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" AFTER DELETE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT sqlitedata_icloud_isUpdatingWithServerRecord() BEGIN + FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN SELECT sqlitedata_icloud_didDelete("old"."recordName"); END """, [3]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_metadataInserts" - AFTER INSERT ON "reminders" FOR EACH ROW BEGIN + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" + AFTER INSERT ON "reminders" + FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'reminders', - "new"."id", - "new"."remindersListID" AS "foreignKey", - datetime('subsec') - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "userModificationDate" = "excluded"."userModificationDate"; + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'reminders', "new"."id", "new"."remindersListID" AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [4]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_metadataUpdates" - AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" + AFTER UPDATE ON "reminders" + FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'reminders', - "new"."id", - "new"."remindersListID" AS "foreignKey", - datetime('subsec') - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "userModificationDate" = "excluded"."userModificationDate"; + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'reminders', "new"."id", "new"."remindersListID" AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [5]: """ @@ -143,47 +121,25 @@ extension BaseCloudKitTests { END """, [12]: """ - CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataInserts" - AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" + AFTER INSERT ON "remindersLists" + FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'remindersLists', - "new"."id", - NULL AS "foreignKey", - datetime('subsec') - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "userModificationDate" = "excluded"."userModificationDate"; + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'remindersLists', "new"."id", NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [13]: """ - CREATE TRIGGER "sqlitedata_icloud_remindersLists_metadataUpdates" - AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'remindersLists', - "new"."id", - NULL AS "foreignKey", - datetime('subsec') - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "userModificationDate" = "excluded"."userModificationDate"; + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'remindersLists', "new"."id", NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [14]: """ @@ -195,47 +151,25 @@ extension BaseCloudKitTests { END """, [15]: """ - CREATE TRIGGER "sqlitedata_icloud_users_metadataInserts" - AFTER INSERT ON "users" FOR EACH ROW BEGIN + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_users" + AFTER INSERT ON "users" + FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'users', - "new"."id", - NULL AS "foreignKey", - datetime('subsec') - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "userModificationDate" = "excluded"."userModificationDate"; + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'users', "new"."id", NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [16]: """ - CREATE TRIGGER "sqlitedata_icloud_users_metadataUpdates" - AFTER UPDATE ON "users" FOR EACH ROW BEGIN + CREATE TRIGGER "sqlitedata_icloud_after_update_on_users" + AFTER UPDATE ON "users" + FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ( - "recordType", - "recordName", - "parentRecordName", - "userModificationDate" - ) - SELECT - 'users', - "new"."id", - NULL AS "foreignKey", - datetime('subsec') - ON CONFLICT("recordName") DO UPDATE - SET - "recordType" = "excluded"."recordType", - "parentRecordName" = "excluded"."parentRecordName", - "userModificationDate" = "excluded"."userModificationDate"; + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'users', "new"."id", NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [17]: """ From 37e72f693306d533d420f70d7388cecd989eebca Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 20 Jun 2025 11:35:18 -0700 Subject: [PATCH 150/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 ++++---- Sources/SharingGRDBCore/CloudKit/Triggers.swift | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c2a8d474..d073b836 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -159,7 +159,7 @@ public final class SyncEngine: Sendable { db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) - for trigger in SyncMetadata.triggers { + for trigger in SyncMetadata.callbackTriggers { try trigger.execute(db) } @@ -233,7 +233,7 @@ public final class SyncEngine: Sendable { for table in self.tables { try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } - for trigger in SyncMetadata.triggers.reversed() { + for trigger in SyncMetadata.callbackTriggers.reversed() { try trigger.drop().execute(db) } db.remove(function: .didDelete(syncEngine: self)) @@ -336,7 +336,7 @@ extension PrimaryKeyedTable { ? foreignKeysByTableName[tableName]?.first(where: \.notnull) : nil - for trigger in triggers(foreignKey: foreignKey) { + for trigger in metadataTriggers(foreignKey: foreignKey) { try trigger.execute(db) } @@ -356,7 +356,7 @@ extension PrimaryKeyedTable { try foreignKey.dropTriggers(for: Self.self, db: db) } - for trigger in triggers(foreignKey: nil).reversed() { + for trigger in metadataTriggers(foreignKey: nil).reversed() { try trigger.drop().execute(db) } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index d2ea066c..26d84ff9 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -2,7 +2,7 @@ import Foundation @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTable { - static func triggers(foreignKey: ForeignKey?) -> [TemporaryTrigger] { + static func metadataTriggers(foreignKey: ForeignKey?) -> [TemporaryTrigger] { [ afterInsert(foreignKey: foreignKey), afterUpdate(foreignKey: foreignKey), @@ -65,7 +65,7 @@ extension SyncMetadata { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - static var triggers: [TemporaryTrigger] { + static var callbackTriggers: [TemporaryTrigger] { [ afterInsertTrigger, afterUpdateTrigger, From c3ceb56cdc61a3c3ada732872c505e79492b3956 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 11:57:44 -0700 Subject: [PATCH 151/581] Trying to make recordName have table name and id --- Sources/SharingGRDB/Exports.swift | 1 - .../CloudKit/CloudKit+StructuredQueries.swift | 23 +- .../CloudKit/CloudKitSharing.swift | 4 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 87 ++- ...wift => SyncMetadata+MacroExpansion.swift} | 106 +--- .../CloudKit/SyncMetadata.swift | 186 ++---- .../CloudKit/SyncMetadataTriggers.swift | 158 +++++ .../CloudKitTests/CloudKitTests.swift | 590 +++++++++--------- .../CloudKitTests/MetadataTests.swift | 18 +- .../Internal/CloudKitTestHelpers.swift | 9 + 10 files changed, 630 insertions(+), 552 deletions(-) rename Sources/SharingGRDBCore/CloudKit/{MetadataTable.swift => SyncMetadata+MacroExpansion.swift} (58%) create mode 100644 Sources/SharingGRDBCore/CloudKit/SyncMetadataTriggers.swift diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift index 103bfe18..07a5c659 100644 --- a/Sources/SharingGRDB/Exports.swift +++ b/Sources/SharingGRDB/Exports.swift @@ -1,3 +1,2 @@ @_exported import SharingGRDBCore @_exported import StructuredQueriesGRDB - diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 8192b8e2..9c9bd589 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -129,18 +129,23 @@ extension PrimaryKeyedTable where TableColumns.PrimaryKey == UUID { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - init(record: CKRecord) { - let recordName = UUID(uuidString: record.recordID.recordName) - if recordName == nil { - reportIssue( - """ - 'recordName' ("\(record.recordID.recordName)") must be a UUID. - """ - ) + init?(record: CKRecord) { + let recordName = RecordName(recordID: record.recordID) + guard let recordName + else { + // TODO: is it ok to make this initializer failable? + return nil } +// if recordName == nil { +// reportIssue( +// """ +// 'recordName' ("\(record.recordID.recordName)") must be a 'recordType' and UUID pair. +// """ +// ) +// } self.init( recordType: record.recordType, - recordName: recordName ?? UUID(), + recordName: recordName, lastKnownServerRecord: record, userModificationDate: record.userModificationDate ) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 17c0bf75..809ab4e3 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -28,7 +28,7 @@ extension SyncEngine { throw CantShareRecordWithParent() } - let recordName = record[keyPath: T.columns.primaryKey.keyPath] + let recordName = SyncMetadata.RecordName(record: record) let metadata = try await metadatabase.read { db in try SyncMetadata @@ -50,7 +50,7 @@ extension SyncEngine { ?? CKRecord( recordType: metadata.recordType, recordID: CKRecord.ID( - recordName: metadata.recordName.uuidString, + recordName: metadata.recordName.rawValue, zoneID: Self.defaultZone.zoneID ) ) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0e2ccd25..cfeadd50 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -274,7 +274,7 @@ public final class SyncEngine: Sendable { ) } - func didUpdate(recordName: String) { + func didUpdate(recordName: SyncMetadata.RecordName) { let zoneID = zoneID(for: recordName) let syncEngine = syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -283,7 +283,7 @@ public final class SyncEngine: Sendable { pendingRecordZoneChanges: [ .saveRecord( CKRecord.ID( - recordName: recordName, + recordName: recordName.rawValue, zoneID: zoneID ) ) @@ -291,7 +291,7 @@ public final class SyncEngine: Sendable { ) } - func didDelete(recordName: String) { + func didDelete(recordName: SyncMetadata.RecordName) { let zoneID = zoneID(for: recordName) let syncEngine = syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -300,7 +300,7 @@ public final class SyncEngine: Sendable { pendingRecordZoneChanges: [ .deleteRecord( CKRecord.ID( - recordName: recordName, + recordName: recordName.rawValue, zoneID: zoneID ) ) @@ -308,12 +308,12 @@ public final class SyncEngine: Sendable { ) } - private func zoneID(for recordName: String) -> CKRecordZone.ID { + private func zoneID(for recordName: SyncMetadata.RecordName) -> CKRecordZone.ID { let metadata = withErrorReporting { try metadatabase.read { db in try SyncMetadata - .find(UUID(uuidString: recordName)!) + .find(recordName) .fetchOne(db) } } ?? nil @@ -456,7 +456,9 @@ extension SyncEngine: CKSyncEngineDelegate { } #endif - guard let metadata = metadataFor(recordID: recordID) + guard + let recordName = SyncMetadata.RecordName(recordID: recordID), + let metadata = metadataFor(recordName: recordName) else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) return nil @@ -492,7 +494,7 @@ extension SyncEngine: CKSyncEngineDelegate { record.parent = metadata.parentRecordName.map { parentRecordName in CKRecord.Reference( recordID: CKRecord.ID( - recordName: parentRecordName.uuidString.lowercased(), + recordName: parentRecordName.rawValue, zoneID: record.recordID.zoneID ), action: .none @@ -661,13 +663,22 @@ extension SyncEngine: CKSyncEngineDelegate { } for failedRecordSave in event.failedRecordSaves { let failedRecord = failedRecordSave.record + guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) + else { + reportIssue(""" + Attempted to delete record with invalid 'recordName': \(failedRecord.recordID.recordName) + + 'recordName' should be formatted as "tableName:uuid". + """) + continue + } func clearServerRecord() { withErrorReporting { try $isUpdatingWithServerRecord.withValue(true) { try database.write { db in try SyncMetadata - .find(recordID: failedRecord.recordID) + .find(recordName) .update { $0.lastKnownServerRecord = nil } .execute(db) } @@ -712,10 +723,19 @@ extension SyncEngine: CKSyncEngineDelegate { guard let rootRecord = metadata.rootRecord else { return } + guard let recordName = SyncMetadata.RecordName(recordID: rootRecord.recordID) + else { + reportIssue(""" + Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) + + 'recordName' should be formatted as "tableName:uuid". + """) + return + } try await database.write { db in try SyncMetadata - .find(recordID: rootRecord.recordID) + .find(recordName) .update { $0.share = share } .execute(db) } @@ -752,9 +772,18 @@ extension SyncEngine: CKSyncEngineDelegate { ) return } + guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) + else { + reportIssue(""" + Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) + + 'recordName' should be formatted as "tableName:uuid". + """) + return + } let userModificationDate = try metadatabase.read { db in - try SyncMetadata.find(recordID: record.recordID).select(\.userModificationDate).fetchOne( + try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( db ) } @@ -807,11 +836,16 @@ extension SyncEngine: CKSyncEngineDelegate { ) // TODO: Append more ON CONFLICT clauses for each unique constraint? // TODO: Use WHERE to scope the update? + guard let metadata = SyncMetadata(record: record) + else { + reportIssue("???") + return + } try database.write { db in try SQLQueryExpression(query).execute(db) try SyncMetadata .insert { - SyncMetadata(record: record) + metadata } onConflictDoUpdate: { $0.lastKnownServerRecord = record $0.userModificationDate = record.userModificationDate @@ -826,13 +860,22 @@ extension SyncEngine: CKSyncEngineDelegate { private func refreshLastKnownServerRecord(_ record: CKRecord) { $isUpdatingWithServerRecord.withValue(true) { - let metadata = metadataFor(recordID: record.recordID) + guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) + else { + reportIssue(""" + Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) + + 'recordName' should be formatted as "tableName:uuid". + """) + return + } + let metadata = metadataFor(recordName: recordName) func updateLastKnownServerRecord() { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in try SyncMetadata - .find(recordID: record.recordID) + .find(recordName) .update { $0.lastKnownServerRecord = record } .execute(db) } @@ -849,10 +892,10 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func metadataFor(recordID: CKRecord.ID) -> SyncMetadata? { + private func metadataFor(recordName: SyncMetadata.RecordName) -> SyncMetadata? { withErrorReporting(.sqliteDataCloudKitFailure) { try metadatabase.read { db in - try SyncMetadata.find(recordID: recordID).fetchOne(db) + try SyncMetadata.find(recordName).fetchOne(db) } } ?? nil @@ -882,7 +925,7 @@ extension DatabaseFunction { private convenience init( _ name: String, - function: @escaping @Sendable (String) -> Void + function: @escaping @Sendable (SyncMetadata.RecordName) -> Void ) { self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 1) { arguments in guard @@ -890,6 +933,16 @@ extension DatabaseFunction { else { return nil } + guard let recordName = SyncMetadata.RecordName(rawValue: recordName) + else { + print(name) + reportIssue(""" + Received 'recordName' in invalid format: \(recordName) + + 'recordName' should be formatted as "tableName:uuid". + """) + return nil + } function(recordName) return nil } diff --git a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift similarity index 58% rename from Sources/SharingGRDBCore/CloudKit/MetadataTable.swift rename to Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 57d0d23c..9bf41d04 100644 --- a/Sources/SharingGRDBCore/CloudKit/MetadataTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -1,94 +1,41 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") -public struct SyncMetadata: Hashable, Sendable { - public var recordType: String - // @Column(primaryKey: true) - public var recordName: UUID - public var parentRecordName: UUID? - // @Column(as: CKRecord?.DataRepresentation.self) - public var lastKnownServerRecord: CKRecord? - // @Column(as: CKShare?.ShareDataRepresentation.self) - public var share: CKShare? - public var userModificationDate: Date? - - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore - .PrimaryKeyedTableDefinition - { +extension SyncMetadata { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { public typealias QueryValue = SyncMetadata - public let recordType = StructuredQueriesCore.TableColumn( - "recordType", - keyPath: \QueryValue.recordType - ) - public let recordName = StructuredQueriesCore.TableColumn( - "recordName", - keyPath: \QueryValue.recordName - ) - public let parentRecordName = StructuredQueriesCore.TableColumn( - "parentRecordName", - keyPath: \QueryValue.parentRecordName - ) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord?.DataRepresentation - >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn< - QueryValue, CKShare?.ShareDataRepresentation - >("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn( - "userModificationDate", - keyPath: \QueryValue.userModificationDate - ) - public var primaryKey: StructuredQueriesCore.TableColumn { + public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) + public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) + public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) + public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + public var primaryKey: StructuredQueriesCore.TableColumn { self.recordName } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [ - QueryValue.columns.recordType, QueryValue.columns.recordName, - QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, - QueryValue.columns.share, QueryValue.columns.userModificationDate, - ] + [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] } } public struct Draft: StructuredQueriesCore.TableDraft { public typealias PrimaryTable = SyncMetadata public var recordType: String - public var recordName: UUID? - public var parentRecordName: UUID? + public var recordName: RecordName? + public var parentRecordName: RecordName? public var lastKnownServerRecord: CKRecord? public var share: CKShare? public var userModificationDate: Date? public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Draft - public let recordType = StructuredQueriesCore.TableColumn( - "recordType", - keyPath: \QueryValue.recordType - ) - public let recordName = StructuredQueriesCore.TableColumn( - "recordName", - keyPath: \QueryValue.recordName - ) - public let parentRecordName = StructuredQueriesCore.TableColumn( - "parentRecordName", - keyPath: \QueryValue.parentRecordName - ) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord?.DataRepresentation - >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn< - QueryValue, CKShare?.ShareDataRepresentation - >("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn( - "userModificationDate", - keyPath: \QueryValue.userModificationDate - ) + public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) + public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) + public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) + public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [ - QueryValue.columns.recordType, QueryValue.columns.recordName, - QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, - QueryValue.columns.share, QueryValue.columns.userModificationDate, - ] + [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] } } public static let columns = TableColumns() @@ -97,8 +44,8 @@ public struct SyncMetadata: Hashable, Sendable { public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) - self.recordName = try decoder.decode(UUID.self) - self.parentRecordName = try decoder.decode(UUID.self) + self.recordName = try decoder.decode(RecordName.self) + self.parentRecordName = try decoder.decode(RecordName.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) @@ -126,8 +73,8 @@ public struct SyncMetadata: Hashable, Sendable { } public init( recordType: String, - recordName: UUID? = nil, - parentRecordName: UUID? = nil, + recordName: RecordName? = nil, + parentRecordName: RecordName? = nil, lastKnownServerRecord: CKRecord? = nil, share: CKShare? = nil, userModificationDate: Date? = nil @@ -142,14 +89,13 @@ public struct SyncMetadata: Hashable, Sendable { } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public static let columns = TableColumns() public static let tableName = "sqlitedata_icloud_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) - let recordName = try decoder.decode(UUID.self) - self.parentRecordName = try decoder.decode(UUID.self) + let recordName = try decoder.decode(RecordName.self) + self.parentRecordName = try decoder.decode(RecordName.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 8d0b8515..67c3e7c7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -1,158 +1,66 @@ import CloudKit -import StructuredQueriesCore @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - fileprivate static let afterInsertTrigger = createTemporaryTrigger( - "after_insert_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .insert { - SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\($0.recordName))" - ) - } when: { _ in - SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - ) +// @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") +public struct SyncMetadata: Hashable, Sendable { + public var recordType: String + // @Column(primaryKey: true) + public var recordName: RecordName + public var parentRecordName: RecordName? + // @Column(as: CKRecord?.DataRepresentation.self) + public var lastKnownServerRecord: CKRecord? + // @Column(as: CKShare?.ShareDataRepresentation.self) + public var share: CKShare? + public var userModificationDate: Date? - fileprivate static let afterUpdateTrigger = createTemporaryTrigger( - "after_update_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .update { _, new in - SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(new.recordName))" - ) - } when: { _, _ in - SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - ) + public struct RecordName: RawRepresentable, Sendable, Hashable, QueryBindable { + public var recordType: String + public var id: UUID - fileprivate static let afterDeleteTrigger = createTemporaryTrigger( - "after_delete_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .delete { - SQLQueryExpression( - "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didDelete(\($0.recordName))" - ) - } when: { _ in - SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + public init>(_ table: T.Type, id: UUID) { + recordType = T.tableName + self.id = id } - ) - - static func createTriggers( - tables: [any PrimaryKeyedTable.Type], - db: Database - ) throws { - try afterInsertTrigger.execute(db) - try afterUpdateTrigger.execute(db) - try afterDeleteTrigger.execute(db) - } - - static func dropTriggers( - tables: [any PrimaryKeyedTable.Type], - db: Database - ) throws { - try afterDeleteTrigger.drop().execute(db) - try afterUpdateTrigger.drop().execute(db) - try afterInsertTrigger.drop().execute(db) - } - - static func createTriggers>( - for _: T.Type, - parentForeignKey: ForeignKey?, - db: Database - ) throws { - let foreignKey = (parentForeignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" - let upsert: QueryFragment = """ - INSERT INTO \(Self.self) - ( - \(quote: recordType.name), - \(quote: recordName.name), - \(quote: parentRecordName.name), - \(quote: userModificationDate.name) - ) - SELECT - \(quote: T.tableName, delimiter: .text), - "new".\(quote: T.columns.primaryKey.name), - \(raw: foreignKey) AS "foreignKey", - datetime('subsec') - ON CONFLICT(\(quote: SyncMetadata.recordName.name)) DO UPDATE - SET - \(quote: recordType.name) = "excluded".\(quote: recordType.name), - \(quote: parentRecordName.name) = "excluded".\(quote: parentRecordName.name), - \(quote: userModificationDate.name) = "excluded".\(quote: userModificationDate.name) - """ + public init?(rawValue: String) { + guard let colonIndex = rawValue.firstIndex(of: ":") + else { + return nil + } + guard let id = UUID(uuidString: String(rawValue[rawValue.index(after: colonIndex)...])) + else { + return nil + } - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS \(insertTriggerName(for: T.self)) - AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN - \(upsert); - END - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS \(updateTriggerName(for: T.self)) - AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN - \(upsert); - END - """ - ) - .execute(db) + recordType = String(rawValue[rawValue.startIndex..>(record: T) { + recordType = T.tableName + id = record[keyPath: T.columns.primaryKey.keyPath] + } - static func dropTriggers>( - for _: T.Type, - db: Database - ) throws { - try T.createDeleteTrigger.drop().execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER \(updateTriggerName(for: T.self)) - """ - ) - .execute(db) - try SQLQueryExpression( - """ - DROP TRIGGER \(insertTriggerName(for: T.self)) - """ - ) - .execute(db) - } + public init?(recordID: CKRecord.ID) { + self.init(rawValue: recordID.recordName) + } - private static func insertTriggerName( - for _: T.Type - ) -> SQLQueryExpression { - SQLQueryExpression( - "\(quote: "\(String.sqliteDataCloudKitSchemaName)_\(T.tableName)_metadataInserts")" - ) + public var rawValue: String { + "\(recordType):\(id.uuidString)" + } } +} - private static func updateTriggerName( - for _: T.Type - ) -> SQLQueryExpression { - SQLQueryExpression( - "\(quote: "\(String.sqliteDataCloudKitSchemaName)_\(T.tableName)_metadataUpdates")" - ) +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension PrimaryKeyedTable { + public static func recordName(for id: UUID) -> SyncMetadata.RecordName { + SyncMetadata.RecordName(Self.self, id: id) } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTable { - fileprivate static var createDeleteTrigger: TemporaryTrigger { - createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)", - ifNotExists: true, - after: .delete { old in - SyncMetadata - .where { $0.recordName.eq(old.primaryKey) } - .delete() - } - ) +extension PrimaryKeyedTableDefinition { + var recordName: some QueryExpression { + SQLQueryExpression("\(quote: QueryValue.tableName, delimiter: .text) || ':' || \(primaryKey)") } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadataTriggers.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadataTriggers.swift new file mode 100644 index 00000000..cfdaf1e4 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadataTriggers.swift @@ -0,0 +1,158 @@ +import CloudKit +import StructuredQueriesCore + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata { + fileprivate static let afterInsertTrigger = createTemporaryTrigger( + "after_insert_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .insert { + SQLQueryExpression( + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\($0.recordName))" + ) + } when: { _ in + SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } + ) + + fileprivate static let afterUpdateTrigger = createTemporaryTrigger( + "after_update_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update { _, new in + SQLQueryExpression( + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(new.recordName))" + ) + } when: { _, _ in + SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } + ) + + fileprivate static let afterDeleteTrigger = createTemporaryTrigger( + "after_delete_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .delete { + SQLQueryExpression( + "SELECT \(raw: .sqliteDataCloudKitSchemaName)_didDelete(\($0.recordName))" + ) + } when: { _ in + SQLQueryExpression("NOT \(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } + ) + + static func createTriggers( + tables: [any PrimaryKeyedTable.Type], + db: Database + ) throws { + try afterInsertTrigger.execute(db) + try afterUpdateTrigger.execute(db) + try afterDeleteTrigger.execute(db) + } + + static func dropTriggers( + tables: [any PrimaryKeyedTable.Type], + db: Database + ) throws { + try afterDeleteTrigger.drop().execute(db) + try afterUpdateTrigger.drop().execute(db) + try afterInsertTrigger.drop().execute(db) + } + + static func createTriggers>( + for _: T.Type, + parentForeignKey: ForeignKey?, + db: Database + ) throws { + let foreignKey = (parentForeignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" + + let upsert: QueryFragment = """ + INSERT INTO \(Self.self) + ( + \(quote: recordType.name), + \(quote: recordName.name), + \(quote: parentRecordName.name), + \(quote: userModificationDate.name) + ) + SELECT + \(quote: T.tableName, delimiter: .text), + \(quote: T.tableName, delimiter: .text) || ':' || "new".\(quote: T.columns.primaryKey.name), + \(raw: foreignKey) AS "foreignKey", + datetime('subsec') + ON CONFLICT(\(quote: SyncMetadata.recordName.name)) DO UPDATE + SET + \(quote: recordType.name) = "excluded".\(quote: recordType.name), + \(quote: parentRecordName.name) = "excluded".\(quote: parentRecordName.name), + \(quote: userModificationDate.name) = "excluded".\(quote: userModificationDate.name) + """ + + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER IF NOT EXISTS \(insertTriggerName(for: T.self)) + AFTER INSERT ON \(T.self) FOR EACH ROW BEGIN + \(upsert); + END + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TEMPORARY TRIGGER IF NOT EXISTS \(updateTriggerName(for: T.self)) + AFTER UPDATE ON \(T.self) FOR EACH ROW BEGIN + \(upsert); + END + """ + ) + .execute(db) + + try T.createDeleteTrigger.execute(db) + } + + static func dropTriggers>( + for _: T.Type, + db: Database + ) throws { + try T.createDeleteTrigger.drop().execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER \(updateTriggerName(for: T.self)) + """ + ) + .execute(db) + try SQLQueryExpression( + """ + DROP TRIGGER \(insertTriggerName(for: T.self)) + """ + ) + .execute(db) + } + + private static func insertTriggerName( + for _: T.Type + ) -> SQLQueryExpression { + SQLQueryExpression( + "\(quote: "\(String.sqliteDataCloudKitSchemaName)_\(T.tableName)_metadataInserts")" + ) + } + + private static func updateTriggerName( + for _: T.Type + ) -> SQLQueryExpression { + SQLQueryExpression( + "\(quote: "\(String.sqliteDataCloudKitSchemaName)_\(T.tableName)_metadataUpdates")" + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension PrimaryKeyedTable { + fileprivate static var createDeleteTrigger: TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)", + ifNotExists: true, + after: .delete { old in + SyncMetadata + .where { $0.recordName.eq(old.recordName) } + .delete() + } + ) + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index fab1a709..43b8129c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -1,297 +1,297 @@ -import CloudKit -import ConcurrencyExtras -import CustomDump -import InlineSnapshotTesting -import SharingGRDB -import SnapshotTestingCustomDump -import Testing - -extension BaseCloudKitTests { - @MainActor - final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func setUp() throws { - let zones = try database.write { db in - try RecordType.all.fetchAll(db) - } - assertInlineSnapshot(of: zones, as: .customDump) { - #""" - [ - [0]: RecordType( - tableName: "remindersLists", - schema: """ - CREATE TABLE "remindersLists" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' - ) STRICT - """ - ), - [1]: RecordType( - tableName: "users", - schema: """ - CREATE TABLE "users" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "name" TEXT NOT NULL DEFAULT '', - "parentUserID" TEXT, - - FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE - ) STRICT - """ - ), - [2]: RecordType( - tableName: "reminders", - schema: """ - CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "assignedUserID" TEXT, - "title" TEXT NOT NULL DEFAULT '', - "parentReminderID" TEXT, - "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', - - FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, - FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, - FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE - ) STRICT - """ - ) - ] - """# - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func tearDown() async throws { - _ = try await database.write { db in - try SyncMetadata.count().fetchOne(db) ?? 0 - } - try await syncEngine.tearDownSyncEngine() -// await #expect(throws: DatabaseError.self) { -// try await self.database.write { db in -// try Metadata.count().fetchOne(db) ?? 0 +//import CloudKit +//import ConcurrencyExtras +//import CustomDump +//import InlineSnapshotTesting +//import SharingGRDB +//import SnapshotTestingCustomDump +//import Testing +// +//extension BaseCloudKitTests { +// @MainActor +// final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func setUp() throws { +// let zones = try database.write { db in +// try RecordType.all.fetchAll(db) +// } +// assertInlineSnapshot(of: zones, as: .customDump) { +// #""" +// [ +// [0]: RecordType( +// tableName: "remindersLists", +// schema: """ +// CREATE TABLE "remindersLists" ( +// "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), +// "title" TEXT NOT NULL DEFAULT '' +// ) STRICT +// """ +// ), +// [1]: RecordType( +// tableName: "users", +// schema: """ +// CREATE TABLE "users" ( +// "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), +// "name" TEXT NOT NULL DEFAULT '', +// "parentUserID" TEXT, +// +// FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE +// ) STRICT +// """ +// ), +// [2]: RecordType( +// tableName: "reminders", +// schema: """ +// CREATE TABLE "reminders" ( +// "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), +// "assignedUserID" TEXT, +// "title" TEXT NOT NULL DEFAULT '', +// "parentReminderID" TEXT, +// "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', +// +// FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, +// FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, +// FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE +// ) STRICT +// """ +// ) +// ] +// """# +// } +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func tearDown() async throws { +// _ = try await database.write { db in +// try SyncMetadata.count().fetchOne(db) ?? 0 +// } +// try await syncEngine.tearDownSyncEngine() +//// await #expect(throws: DatabaseError.self) { +//// try await self.database.write { db in +//// try Metadata.count().fetchOne(db) ?? 0 +//// } +//// } +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func tearDownAndReSetUp() async throws { +// try await syncEngine.tearDownSyncEngine() +// try await syncEngine.setUpSyncEngine() +// privateSyncEngine.assertFetchChangesScopes([.all]) +// sharedSyncEngine.assertFetchChangesScopes([.all]) +// +// try await database.write { db in +// try db.seed { +// RemindersList(id: UUID(1), title: "Personal") +// } +// } +// privateSyncEngine.state.assertPendingRecordZoneChanges([ +// .saveRecord(CKRecord.ID(UUID(1))) +// ]) +// +// +// let record = CKRecord( +// recordType: "remindersLists", +// recordID: CKRecord.ID(UUID(1)) +// ) +// await syncEngine.handleFetchedRecordZoneChanges( +// modifications: [record], +// deletions: [] +// ) +// expectNoDifference( +// try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), +// RemindersList(id: UUID(1), title: "Personal") +// ) +// +// let metadata = +// try await database.write { db in +// try SyncMetadata.find(UUID(1)).fetchOne(db) +// } +// #expect(metadata != nil) +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func addAndRemoveFunctions() async throws { +// let query = #sql( +// """ +// SELECT name +// FROM pragma_function_list +// WHERE name LIKE \(bind: String.sqliteDataCloudKitSchemaName + "_%") +// """, +// as: String.self +// ) +// assertInlineSnapshot( +// of: try { try database.write { try query.fetchAll($0) } }(), +// as: .customDump +// ) { +// """ +// [ +// [0]: "sqlitedata_icloud_didupdate", +// [1]: "sqlitedata_icloud_isupdatingwithserverrecord", +// [2]: "sqlitedata_icloud_diddelete" +// ] +// """ +// } +// try await syncEngine.tearDownSyncEngine() +// +// assertInlineSnapshot( +// of: try { try database.write { try query.fetchAll($0) } }(), +// as: .customDump +// ) { +// """ +// [] +// """ +// } +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func migration() async throws { +// // TODO: how to test what happens after a migration? need to assert that zones are fetched. +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func insertUpdateDelete() throws { +// try database.write { db in +// try RemindersList +// .insert { RemindersList(id: UUID(1), title: "Personal") } +// .execute(db) +// } +// privateSyncEngine.state.assertPendingRecordZoneChanges([ +// .saveRecord(CKRecord.ID(UUID(1))) +// ]) +// try database.write { db in +// try RemindersList +// .find(UUID(1)) +// .update { $0.title = "Work" } +// .execute(db) +// } +// privateSyncEngine.state.assertPendingRecordZoneChanges([ +// .saveRecord(CKRecord.ID(UUID(1))) +// ]) +// try database.write { db in +// try RemindersList +// .find(UUID(1)) +// .delete() +// .execute(db) +// } +// privateSyncEngine.state.assertPendingRecordZoneChanges([ +// .deleteRecord(CKRecord.ID(UUID(1))) +// ]) +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func remoteServerRecordUpdate() async throws { +// try await database.write { db in +// try db.seed { +// RemindersList(id: UUID(1), title: "Personal") +// } +// } +// privateSyncEngine.state.assertPendingRecordZoneChanges([ +// .saveRecord(CKRecord.ID(UUID(1))) +// ]) +// +// let record = CKRecord( +// recordType: "remindersLists", +// recordID: CKRecord.ID(UUID(1)) +// ) +// let userModificationDate = try #require( +// try await database.write { db in +// try SyncMetadata.find(UUID(1)).select(\.userModificationDate).fetchOne(db) ?? nil +// } +// ) +// +// // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? +// record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString +// record.encryptedValues[RemindersList.columns.title.name] = "Work" +// let serverModificationDate = userModificationDate.addingTimeInterval(60) +// record.userModificationDate = serverModificationDate +// await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) +// expectNoDifference( +// try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), +// RemindersList(id: UUID(1), title: "Work") +// ) +// +// let metadata = try #require( +// try await database.write { db in +// try SyncMetadata.find(UUID(1)).fetchOne(db) +// } +// ) +// // TODO: Control dates in SQLite in order to get consistent passing on float comparison +// #expect(abs(metadata.userModificationDate!.timeIntervalSince(serverModificationDate)) < 0.1) +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func remoteServerRecordUpdateWithOldRecord() async throws { +// try await database.write { db in +// try db.seed { +// RemindersList(id: UUID(1), title: "Personal") // } // } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func tearDownAndReSetUp() async throws { - try await syncEngine.tearDownSyncEngine() - try await syncEngine.setUpSyncEngine() - privateSyncEngine.assertFetchChangesScopes([.all]) - sharedSyncEngine.assertFetchChangesScopes([.all]) - - try await database.write { db in - try db.seed { - RemindersList(id: UUID(1), title: "Personal") - } - } - privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))) - ]) - - - let record = CKRecord( - recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1)) - ) - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [record], - deletions: [] - ) - expectNoDifference( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), - RemindersList(id: UUID(1), title: "Personal") - ) - - let metadata = - try await database.write { db in - try SyncMetadata.find(UUID(1)).fetchOne(db) - } - #expect(metadata != nil) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addAndRemoveFunctions() async throws { - let query = #sql( - """ - SELECT name - FROM pragma_function_list - WHERE name LIKE \(bind: String.sqliteDataCloudKitSchemaName + "_%") - """, - as: String.self - ) - assertInlineSnapshot( - of: try { try database.write { try query.fetchAll($0) } }(), - as: .customDump - ) { - """ - [ - [0]: "sqlitedata_icloud_didupdate", - [1]: "sqlitedata_icloud_isupdatingwithserverrecord", - [2]: "sqlitedata_icloud_diddelete" - ] - """ - } - try await syncEngine.tearDownSyncEngine() - - assertInlineSnapshot( - of: try { try database.write { try query.fetchAll($0) } }(), - as: .customDump - ) { - """ - [] - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func migration() async throws { - // TODO: how to test what happens after a migration? need to assert that zones are fetched. - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func insertUpdateDelete() throws { - try database.write { db in - try RemindersList - .insert { RemindersList(id: UUID(1), title: "Personal") } - .execute(db) - } - privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))) - ]) - try database.write { db in - try RemindersList - .find(UUID(1)) - .update { $0.title = "Work" } - .execute(db) - } - privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))) - ]) - try database.write { db in - try RemindersList - .find(UUID(1)) - .delete() - .execute(db) - } - privateSyncEngine.state.assertPendingRecordZoneChanges([ - .deleteRecord(CKRecord.ID(UUID(1))) - ]) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteServerRecordUpdate() async throws { - try await database.write { db in - try db.seed { - RemindersList(id: UUID(1), title: "Personal") - } - } - privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))) - ]) - - let record = CKRecord( - recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1)) - ) - let userModificationDate = try #require( - try await database.write { db in - try SyncMetadata.find(UUID(1)).select(\.userModificationDate).fetchOne(db) ?? nil - } - ) - - // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? - record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString - record.encryptedValues[RemindersList.columns.title.name] = "Work" - let serverModificationDate = userModificationDate.addingTimeInterval(60) - record.userModificationDate = serverModificationDate - await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) - expectNoDifference( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), - RemindersList(id: UUID(1), title: "Work") - ) - - let metadata = try #require( - try await database.write { db in - try SyncMetadata.find(UUID(1)).fetchOne(db) - } - ) - // TODO: Control dates in SQLite in order to get consistent passing on float comparison - #expect(abs(metadata.userModificationDate!.timeIntervalSince(serverModificationDate)) < 0.1) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteServerRecordUpdateWithOldRecord() async throws { - try await database.write { db in - try db.seed { - RemindersList(id: UUID(1), title: "Personal") - } - } - privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))) - ]) - let record = CKRecord( - recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1)) - ) - let userModificationDate = try #require( - try await database.write { db in - try SyncMetadata - .find(UUID(1)) - .select(\.userModificationDate) - .fetchOne(db) ?? nil - } - ) - - // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? - record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString - record.encryptedValues[RemindersList.columns.title.name] = "Work" - let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) - record.userModificationDate = serverModificationDate - await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) - expectNoDifference( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), - RemindersList(id: UUID(1), title: "Personal") - ) - - let metadata = try #require( - try await database.write { db in - try SyncMetadata.find(UUID(1)).fetchOne(db) - } - ) - #expect(metadata.userModificationDate == userModificationDate) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteServerRecordDeleted() async throws { - try await database.write { db in - try db.seed { - RemindersList(id: UUID(1), title: "Personal") - } - } - privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))) - ]) - - let record = CKRecord( - recordType: "remindersLists", - recordID: CKRecord.ID(UUID(1)) - ) - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [], - deletions: [(record.recordID, record.recordType)] - ) - #expect( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() - == 0 - ) - let metadata = try await database.write { db in - try SyncMetadata.find(UUID(1)).fetchOne(db) - } - #expect(metadata == nil) - } - } - - // TODO: Test what happens when we delete locally and then an edit comes in from the server -} - - +// privateSyncEngine.state.assertPendingRecordZoneChanges([ +// .saveRecord(CKRecord.ID(UUID(1))) +// ]) +// let record = CKRecord( +// recordType: "remindersLists", +// recordID: CKRecord.ID(UUID(1)) +// ) +// let userModificationDate = try #require( +// try await database.write { db in +// try SyncMetadata +// .find(UUID(1)) +// .select(\.userModificationDate) +// .fetchOne(db) ?? nil +// } +// ) +// +// // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? +// record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString +// record.encryptedValues[RemindersList.columns.title.name] = "Work" +// let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) +// record.userModificationDate = serverModificationDate +// await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) +// expectNoDifference( +// try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), +// RemindersList(id: UUID(1), title: "Personal") +// ) +// +// let metadata = try #require( +// try await database.write { db in +// try SyncMetadata.find(UUID(1)).fetchOne(db) +// } +// ) +// #expect(metadata.userModificationDate == userModificationDate) +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func remoteServerRecordDeleted() async throws { +// try await database.write { db in +// try db.seed { +// RemindersList(id: UUID(1), title: "Personal") +// } +// } +// privateSyncEngine.state.assertPendingRecordZoneChanges([ +// .saveRecord(CKRecord.ID(UUID(1))) +// ]) +// +// let record = CKRecord( +// recordType: "remindersLists", +// recordID: CKRecord.ID(UUID(1)) +// ) +// await syncEngine.handleFetchedRecordZoneChanges( +// modifications: [], +// deletions: [(record.recordID, record.recordType)] +// ) +// #expect( +// try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() +// == 0 +// ) +// let metadata = try await database.write { db in +// try SyncMetadata.find(UUID(1)).fetchOne(db) +// } +// #expect(metadata == nil) +// } +// } +// +// // TODO: Test what happens when we delete locally and then an edit comes in from the server +//} +// +// diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 1308836d..3114ea06 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -15,22 +15,22 @@ extension BaseCloudKitTests { try db.seed { RemindersList(id: UUID(1), title: "Personal") RemindersList(id: UUID(2), title: "Work") - Reminder(id: UUID(3), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) } } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))), - .saveRecord(CKRecord.ID(UUID(2))), - .saveRecord(CKRecord.ID(UUID(3))), + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(RemindersList.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(1))), ]) try database.write { db in let reminderMetadata = try #require( try SyncMetadata - .find(UUID(3)) + .find(Reminder.recordName(for: UUID(1))) .fetchOne(db) ) - #expect(reminderMetadata.parentRecordName == UUID(1)) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(1))) } try database.write { db in @@ -41,13 +41,13 @@ extension BaseCloudKitTests { try database.write { db in let reminderMetadata = try #require( try SyncMetadata - .find(UUID(3)) + .find(Reminder.recordName(for: UUID(1))) .fetchOne(db) ) - #expect(reminderMetadata.parentRecordName == UUID(2)) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(2))) } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(3))), + .saveRecord(Reminder.recordID(for: UUID(1))), ]) } } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 96c4f3ed..62e4640a 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -13,6 +13,15 @@ extension CKRecord.ID { } } +extension PrimaryKeyedTable { + static func recordID(for id: UUID) -> CKRecord.ID { + CKRecord.ID( + recordName: "\(Self.tableName):\(id.uuidString)", + zoneID: SyncEngine.defaultZone.zoneID + ) + } +} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) final class MockSyncEngine: SyncEngineProtocol { private let _state: LockIsolated From 63590293bbcc07e5aabf72be0bb0744046ee464d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 12:11:08 -0700 Subject: [PATCH 152/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncMetadataTriggers.swift | 4 +++- Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift | 2 +- Tests/SharingGRDBTests/Internal/Schema.swift | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadataTriggers.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadataTriggers.swift index cfdaf1e4..f0d1ac2b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadataTriggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadataTriggers.swift @@ -62,7 +62,9 @@ extension SyncMetadata { parentForeignKey: ForeignKey?, db: Database ) throws { - let foreignKey = (parentForeignKey?.from).map { #""new"."\#($0)""# } ?? "NULL" + let foreignKey = parentForeignKey.map { + #"'\#($0.table)' || ':' || "new"."\#($0.from)""# + } ?? "NULL" let upsert: QueryFragment = """ INSERT INTO \(Self.self) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 3114ea06..cc3d4ab0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -34,7 +34,7 @@ extension BaseCloudKitTests { } try database.write { db in - try Reminder.find(UUID(3)) + try Reminder.find(UUID(1)) .update { $0.remindersListID = UUID(2) } .execute(db) } diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 8c5fe6d3..6df98b55 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -22,6 +22,11 @@ import SharingGRDB func database() throws -> DatabasePool { var configuration = Configuration() configuration.foreignKeysEnabled = false + configuration.prepareDatabase { db in + db.trace { + print($0.expandedDescription) + } + } let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") let database = try DatabasePool(path: url.path(), configuration: configuration) try database.write { db in From b2621a38ea8d84e6e2846cd87c49d0f15b890505 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 20 Jun 2025 12:23:11 -0700 Subject: [PATCH 153/581] wip --- .../SharingGRDBCore/CloudKit/ForeignKey.swift | 59 ++++++++++--------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 13 +++- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index 767f1348..e1c68928 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -1,3 +1,4 @@ +import Foundation import StructuredQueriesCore struct ForeignKey: QueryDecodable, QueryRepresentable { @@ -57,16 +58,20 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ } - func createTriggers(for _: T.Type, db: Database) throws { + func createTriggers, P: PrimaryKeyedTable>( + _: C.Type, + belongsTo _: P.Type, + db: Database + ) throws { switch onDelete { case .cascade: try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteCascade" - AFTER DELETE ON \(quote: table) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onDeleteCascade" + AFTER DELETE ON \(P.self) FOR EACH ROW BEGIN - DELETE FROM \(T.self) + DELETE FROM \(C.self) WHERE \(quote: from) = "old".\(quote: to); END """ @@ -77,11 +82,11 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteRestrict" - AFTER DELETE ON \(quote: table) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onDeleteRestrict" + AFTER DELETE ON \(P.self) FOR EACH ROW BEGIN SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM \(T.self) + FROM \(C.self) WHERE \(quote: from) = "old".\(quote: to); END """ @@ -93,7 +98,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ SELECT "dflt_value" - FROM pragma_table_info(\(bind: T.tableName)) + FROM pragma_table_info(\(bind: C.tableName)) WHERE "name" = \(bind: from) """, as: String?.self @@ -103,10 +108,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetDefault" - AFTER DELETE ON \(quote: table) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onDeleteSetDefault" + AFTER DELETE ON \(P.self) FOR EACH ROW BEGIN - UPDATE \(T.self) + UPDATE \(C.self) SET \(quote: from) = \(raw: defaultValue ?? "NULL") WHERE \(quote: from) = "old".\(quote: to); END @@ -118,10 +123,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetNull" - AFTER DELETE ON \(quote: table) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onDeleteSetNull" + AFTER DELETE ON \(P.self) FOR EACH ROW BEGIN - UPDATE \(T.self) + UPDATE \(C.self) SET \(quote: from) = NULL WHERE \(quote: from) = "old".\(quote: to); END @@ -137,10 +142,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateCascade" - AFTER UPDATE ON \(quote: table) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onUpdateCascade" + AFTER UPDATE ON \(P.self) FOR EACH ROW BEGIN - UPDATE \(T.self) + UPDATE \(C.self) SET \(quote: from) = "new".\(quote: to) WHERE \(quote: from) = "old".\(quote: to); END @@ -152,11 +157,11 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateRestrict" - AFTER UPDATE ON \(quote: table) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onUpdateRestrict" + AFTER UPDATE ON \(P.self) FOR EACH ROW BEGIN SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM \(T.self) + FROM \(C.self) WHERE \(quote: from) = "old".\(quote: to); END """ @@ -168,7 +173,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ SELECT "dflt_value" - FROM pragma_table_info(\(bind: T.tableName)) + FROM pragma_table_info(\(bind: C.tableName)) WHERE "name" = \(bind: from) """, as: String?.self @@ -178,10 +183,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetDefault" - AFTER UPDATE ON \(quote: table) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onUpdateSetDefault" + AFTER UPDATE ON \(P.self) FOR EACH ROW BEGIN - UPDATE \(T.self) + UPDATE \(C.self) SET \(quote: from) = \(raw: defaultValue ?? "NULL") WHERE \(quote: from) = "old".\(quote: to); END @@ -193,10 +198,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetNull" - AFTER UPDATE ON \(quote: table) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onUpdateSetNull" + AFTER UPDATE ON \(P.self) FOR EACH ROW BEGIN - UPDATE \(T.self) + UPDATE \(C.self) SET \(quote: from) = NULL WHERE \(quote: from) = "old".\(quote: to); END diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d073b836..c1533888 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -164,7 +164,11 @@ public final class SyncEngine: Sendable { } for table in tables { - try table.createTriggers(foreignKeysByTableName: foreignKeysByTableName, db: db) + try table.createTriggers( + foreignKeysByTableName: foreignKeysByTableName, + tablesByName: tablesByName, + db: db + ) } } @@ -329,6 +333,7 @@ extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) fileprivate static func createTriggers( foreignKeysByTableName: [String: [ForeignKey]], + tablesByName: [String: any PrimaryKeyedTable.Type], db: Database ) throws { let foreignKey = @@ -342,7 +347,11 @@ extension PrimaryKeyedTable { let foreignKeys = foreignKeysByTableName[tableName] ?? [] for foreignKey in foreignKeys { - try foreignKey.createTriggers(for: Self.self, db: db) + guard let parent = tablesByName[foreignKey.table] else { + reportIssue("") + continue + } + try foreignKey.createTriggers(Self.self, belongsTo: parent, db: db) } } From b7d7ee3f702e7092ad81bd4ccea8d95879df593a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 12:53:06 -0700 Subject: [PATCH 154/581] all tests passing --- .../CloudKit/CloudKit+StructuredQueries.swift | 30 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 14 +- .../CloudKitTests/CloudKitTests.swift | 599 +++++++++--------- .../CloudKitTests/ForeignKeyTests.swift | 55 +- .../CloudKitTests/TriggerTests.swift | 18 +- .../Internal/CloudKitTestHelpers.swift | 18 +- 6 files changed, 375 insertions(+), 359 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 9c9bd589..887f2977 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -111,21 +111,21 @@ extension CKRecord { "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" } -extension PrimaryKeyedTable where TableColumns.PrimaryKey == UUID { - static func find(recordID: CKRecord.ID) -> Where { - let recordName = UUID(uuidString: recordID.recordName) - if recordName == nil { - reportIssue( - """ - 'recordName' ("\(recordID.recordName)") must be a UUID. - """ - ) - } - return Self.where { - $0.primaryKey.eq(recordName ?? UUID()) - } - } -} +//extension PrimaryKeyedTable where TableColumns.PrimaryKey == UUID { +// static func find(recordID: CKRecord.ID) -> Where { +// let recordName = UUID(uuidString: recordID.recordName) +// if recordName == nil { +// reportIssue( +// """ +// 'recordName' ("\(recordID.recordName)") must be a UUID. +// """ +// ) +// } +// return Self.where { +// $0.primaryKey.eq(recordName ?? UUID()) +// } +// } +//} @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ca374b83..d54944f0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -492,7 +492,7 @@ extension SyncEngine: CKSyncEngineDelegate { let row = withErrorReporting { try database.read { db in - try T.find(recordID: recordID).fetchOne(db) + try T.find(recordName.id).fetchOne(db) } } ?? nil @@ -636,11 +636,20 @@ extension SyncEngine: CKSyncEngineDelegate { } for (recordID, recordType) in deletions { + guard let recordName = SyncMetadata.RecordName(recordID: recordID) + else { + reportIssue(""" + Received 'recordName' in invalid format: \(recordID.recordName) + + 'recordName' should be formatted as "tableName:uuid". + """) + continue + } if let table = tablesByName[recordType] { func open>(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in - try T.find(recordID: recordID) + try T.find(recordName.id) .delete() .execute(db) } @@ -953,7 +962,6 @@ extension DatabaseFunction { } guard let recordName = SyncMetadata.RecordName(rawValue: recordName) else { - print(name) reportIssue(""" Received 'recordName' in invalid format: \(recordName) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 43b8129c..e9d51345 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -1,297 +1,306 @@ -//import CloudKit -//import ConcurrencyExtras -//import CustomDump -//import InlineSnapshotTesting -//import SharingGRDB -//import SnapshotTestingCustomDump -//import Testing -// -//extension BaseCloudKitTests { -// @MainActor -// final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func setUp() throws { -// let zones = try database.write { db in -// try RecordType.all.fetchAll(db) -// } -// assertInlineSnapshot(of: zones, as: .customDump) { -// #""" -// [ -// [0]: RecordType( -// tableName: "remindersLists", -// schema: """ -// CREATE TABLE "remindersLists" ( -// "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), -// "title" TEXT NOT NULL DEFAULT '' -// ) STRICT -// """ -// ), -// [1]: RecordType( -// tableName: "users", -// schema: """ -// CREATE TABLE "users" ( -// "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), -// "name" TEXT NOT NULL DEFAULT '', -// "parentUserID" TEXT, -// -// FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE -// ) STRICT -// """ -// ), -// [2]: RecordType( -// tableName: "reminders", -// schema: """ -// CREATE TABLE "reminders" ( -// "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), -// "assignedUserID" TEXT, -// "title" TEXT NOT NULL DEFAULT '', -// "parentReminderID" TEXT, -// "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', -// -// FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, -// FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, -// FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE -// ) STRICT -// """ -// ) -// ] -// """# -// } -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func tearDown() async throws { -// _ = try await database.write { db in -// try SyncMetadata.count().fetchOne(db) ?? 0 -// } -// try await syncEngine.tearDownSyncEngine() -//// await #expect(throws: DatabaseError.self) { -//// try await self.database.write { db in -//// try Metadata.count().fetchOne(db) ?? 0 -//// } -//// } -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func tearDownAndReSetUp() async throws { -// try await syncEngine.tearDownSyncEngine() -// try await syncEngine.setUpSyncEngine() -// privateSyncEngine.assertFetchChangesScopes([.all]) -// sharedSyncEngine.assertFetchChangesScopes([.all]) -// -// try await database.write { db in -// try db.seed { -// RemindersList(id: UUID(1), title: "Personal") -// } -// } -// privateSyncEngine.state.assertPendingRecordZoneChanges([ -// .saveRecord(CKRecord.ID(UUID(1))) -// ]) -// -// -// let record = CKRecord( -// recordType: "remindersLists", -// recordID: CKRecord.ID(UUID(1)) -// ) -// await syncEngine.handleFetchedRecordZoneChanges( -// modifications: [record], -// deletions: [] -// ) -// expectNoDifference( -// try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), -// RemindersList(id: UUID(1), title: "Personal") -// ) -// -// let metadata = -// try await database.write { db in -// try SyncMetadata.find(UUID(1)).fetchOne(db) -// } -// #expect(metadata != nil) -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func addAndRemoveFunctions() async throws { -// let query = #sql( -// """ -// SELECT name -// FROM pragma_function_list -// WHERE name LIKE \(bind: String.sqliteDataCloudKitSchemaName + "_%") -// """, -// as: String.self -// ) -// assertInlineSnapshot( -// of: try { try database.write { try query.fetchAll($0) } }(), -// as: .customDump -// ) { -// """ -// [ -// [0]: "sqlitedata_icloud_didupdate", -// [1]: "sqlitedata_icloud_isupdatingwithserverrecord", -// [2]: "sqlitedata_icloud_diddelete" -// ] -// """ -// } -// try await syncEngine.tearDownSyncEngine() -// -// assertInlineSnapshot( -// of: try { try database.write { try query.fetchAll($0) } }(), -// as: .customDump -// ) { -// """ -// [] -// """ -// } -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func migration() async throws { -// // TODO: how to test what happens after a migration? need to assert that zones are fetched. -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func insertUpdateDelete() throws { -// try database.write { db in -// try RemindersList -// .insert { RemindersList(id: UUID(1), title: "Personal") } -// .execute(db) -// } -// privateSyncEngine.state.assertPendingRecordZoneChanges([ -// .saveRecord(CKRecord.ID(UUID(1))) -// ]) -// try database.write { db in -// try RemindersList -// .find(UUID(1)) -// .update { $0.title = "Work" } -// .execute(db) -// } -// privateSyncEngine.state.assertPendingRecordZoneChanges([ -// .saveRecord(CKRecord.ID(UUID(1))) -// ]) -// try database.write { db in -// try RemindersList -// .find(UUID(1)) -// .delete() -// .execute(db) -// } -// privateSyncEngine.state.assertPendingRecordZoneChanges([ -// .deleteRecord(CKRecord.ID(UUID(1))) -// ]) -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func remoteServerRecordUpdate() async throws { -// try await database.write { db in -// try db.seed { -// RemindersList(id: UUID(1), title: "Personal") -// } -// } -// privateSyncEngine.state.assertPendingRecordZoneChanges([ -// .saveRecord(CKRecord.ID(UUID(1))) -// ]) -// -// let record = CKRecord( -// recordType: "remindersLists", -// recordID: CKRecord.ID(UUID(1)) -// ) -// let userModificationDate = try #require( -// try await database.write { db in -// try SyncMetadata.find(UUID(1)).select(\.userModificationDate).fetchOne(db) ?? nil -// } -// ) -// -// // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? -// record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString -// record.encryptedValues[RemindersList.columns.title.name] = "Work" -// let serverModificationDate = userModificationDate.addingTimeInterval(60) -// record.userModificationDate = serverModificationDate -// await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) -// expectNoDifference( -// try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), -// RemindersList(id: UUID(1), title: "Work") -// ) -// -// let metadata = try #require( -// try await database.write { db in -// try SyncMetadata.find(UUID(1)).fetchOne(db) -// } -// ) -// // TODO: Control dates in SQLite in order to get consistent passing on float comparison -// #expect(abs(metadata.userModificationDate!.timeIntervalSince(serverModificationDate)) < 0.1) -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func remoteServerRecordUpdateWithOldRecord() async throws { -// try await database.write { db in -// try db.seed { -// RemindersList(id: UUID(1), title: "Personal") +import CloudKit +import ConcurrencyExtras +import CustomDump +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func setUp() throws { + let zones = try database.write { db in + try RecordType.all.fetchAll(db) + } + assertInlineSnapshot(of: zones, as: .customDump) { + #""" + [ + [0]: RecordType( + tableName: "remindersLists", + schema: """ + CREATE TABLE "remindersLists" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ), + [1]: RecordType( + tableName: "users", + schema: """ + CREATE TABLE "users" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "name" TEXT NOT NULL DEFAULT '', + "parentUserID" TEXT, + + FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE + ) STRICT + """ + ), + [2]: RecordType( + tableName: "reminders", + schema: """ + CREATE TABLE "reminders" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "assignedUserID" TEXT, + "title" TEXT NOT NULL DEFAULT '', + "parentReminderID" TEXT, + "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + + FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """ + ) + ] + """# + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDown() async throws { + _ = try await database.write { db in + try SyncMetadata.count().fetchOne(db) ?? 0 + } + try await syncEngine.tearDownSyncEngine() +// await #expect(throws: DatabaseError.self) { +// try await self.database.write { db in +// try Metadata.count().fetchOne(db) ?? 0 // } // } -// privateSyncEngine.state.assertPendingRecordZoneChanges([ -// .saveRecord(CKRecord.ID(UUID(1))) -// ]) -// let record = CKRecord( -// recordType: "remindersLists", -// recordID: CKRecord.ID(UUID(1)) -// ) -// let userModificationDate = try #require( -// try await database.write { db in -// try SyncMetadata -// .find(UUID(1)) -// .select(\.userModificationDate) -// .fetchOne(db) ?? nil -// } -// ) -// -// // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? -// record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString -// record.encryptedValues[RemindersList.columns.title.name] = "Work" -// let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) -// record.userModificationDate = serverModificationDate -// await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) -// expectNoDifference( -// try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), -// RemindersList(id: UUID(1), title: "Personal") -// ) -// -// let metadata = try #require( -// try await database.write { db in -// try SyncMetadata.find(UUID(1)).fetchOne(db) -// } -// ) -// #expect(metadata.userModificationDate == userModificationDate) -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func remoteServerRecordDeleted() async throws { -// try await database.write { db in -// try db.seed { -// RemindersList(id: UUID(1), title: "Personal") -// } -// } -// privateSyncEngine.state.assertPendingRecordZoneChanges([ -// .saveRecord(CKRecord.ID(UUID(1))) -// ]) -// -// let record = CKRecord( -// recordType: "remindersLists", -// recordID: CKRecord.ID(UUID(1)) -// ) -// await syncEngine.handleFetchedRecordZoneChanges( -// modifications: [], -// deletions: [(record.recordID, record.recordType)] -// ) -// #expect( -// try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() -// == 0 -// ) -// let metadata = try await database.write { db in -// try SyncMetadata.find(UUID(1)).fetchOne(db) -// } -// #expect(metadata == nil) -// } -// } -// -// // TODO: Test what happens when we delete locally and then an edit comes in from the server -//} -// -// + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDownAndReSetUp() async throws { + try await syncEngine.tearDownSyncEngine() + try await syncEngine.setUpSyncEngine() + privateSyncEngine.assertFetchChangesScopes([.all]) + sharedSyncEngine.assertFetchChangesScopes([.all]) + + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + + + let record = CKRecord( + recordType: "remindersLists", + recordID: RemindersList.recordID(for: UUID(1)) + ) + await syncEngine.handleFetchedRecordZoneChanges( + modifications: [record], + deletions: [] + ) + expectNoDifference( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + RemindersList(id: UUID(1), title: "Personal") + ) + + let metadata = + try await database.write { db in + try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) + } + #expect(metadata != nil) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addAndRemoveFunctions() async throws { + let query = #sql( + """ + SELECT name + FROM pragma_function_list + WHERE name LIKE \(bind: String.sqliteDataCloudKitSchemaName + "_%") + """, + as: String.self + ) + assertInlineSnapshot( + of: try { try database.write { try query.fetchAll($0) } }(), + as: .customDump + ) { + """ + [ + [0]: "sqlitedata_icloud_didupdate", + [1]: "sqlitedata_icloud_isupdatingwithserverrecord", + [2]: "sqlitedata_icloud_diddelete" + ] + """ + } + try await syncEngine.tearDownSyncEngine() + + assertInlineSnapshot( + of: try { try database.write { try query.fetchAll($0) } }(), + as: .customDump + ) { + """ + [] + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func migration() async throws { + // TODO: how to test what happens after a migration? need to assert that zones are fetched. + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertUpdateDelete() throws { + try database.write { db in + try RemindersList + .insert { RemindersList(id: UUID(1), title: "Personal") } + .execute(db) + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + try database.write { db in + try RemindersList + .find(UUID(1)) + .update { $0.title = "Work" } + .execute(db) + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + try database.write { db in + try RemindersList + .find(UUID(1)) + .delete() + .execute(db) + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .deleteRecord(RemindersList.recordID(for: UUID(1))) + ]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordUpdate() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + + let record = CKRecord( + recordType: "remindersLists", + recordID: RemindersList.recordID(for: UUID(1)) + ) + let userModificationDate = try #require( + try await database.write { db in + try SyncMetadata + .find(RemindersList.recordName(for: UUID(1))) + .select(\.userModificationDate) + .fetchOne(db) ?? nil + } + ) + + // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? + record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString + record.encryptedValues[RemindersList.columns.title.name] = "Work" + let serverModificationDate = userModificationDate.addingTimeInterval(60) + record.userModificationDate = serverModificationDate + await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) + expectNoDifference( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + RemindersList(id: UUID(1), title: "Work") + ) + + let metadata = try #require( + try await database.write { db in + try SyncMetadata + .find(RemindersList.recordName(for: UUID(1))) + .fetchOne(db) + } + ) + // TODO: Control dates in SQLite in order to get consistent passing on float comparison + #expect(abs(metadata.userModificationDate!.timeIntervalSince(serverModificationDate)) < 0.1) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordUpdateWithOldRecord() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + let record = CKRecord( + recordType: "remindersLists", + recordID: RemindersList.recordID(for: UUID(1)) + ) + let userModificationDate = try #require( + try await database.write { db in + try SyncMetadata + .find(RemindersList.recordName(for: UUID(1))) + .select(\.userModificationDate) + .fetchOne(db) ?? nil + } + ) + + // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? + record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString + record.encryptedValues[RemindersList.columns.title.name] = "Work" + let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) + record.userModificationDate = serverModificationDate + await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) + expectNoDifference( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + RemindersList(id: UUID(1), title: "Personal") + ) + + let metadata = try #require( + try await database.write { db in + try SyncMetadata + .find(RemindersList.recordName(for: UUID(1))) + .fetchOne(db) + } + ) + #expect(metadata.userModificationDate == userModificationDate) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordDeleted() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + + let record = CKRecord( + recordType: "remindersLists", + recordID: RemindersList.recordID(for: UUID(1)) + ) + await syncEngine.handleFetchedRecordZoneChanges( + modifications: [], + deletions: [(record.recordID, record.recordType)] + ) + #expect( + try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() + == 0 + ) + let metadata = try await database.write { db in + try SyncMetadata + .find(RemindersList.recordName(for: UUID(1))) + .fetchOne(db) + } + #expect(metadata == nil) + } + } + + // TODO: Test what happens when we delete locally and then an edit comes in from the server +} + + diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 2e65edfd..051c3de5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -20,10 +20,10 @@ extension BaseCloudKitTests { } } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))), - .saveRecord(CKRecord.ID(UUID(1))), - .saveRecord(CKRecord.ID(UUID(2))), - .saveRecord(CKRecord.ID(UUID(3))), + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(3))), ]) try database.write { db in try RemindersList.find(UUID(1)).delete().execute(db) @@ -32,10 +32,10 @@ extension BaseCloudKitTests { try #expect(Reminder.all.fetchAll(db) == []) } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .deleteRecord(CKRecord.ID(UUID(1))), - .deleteRecord(CKRecord.ID(UUID(1))), - .deleteRecord(CKRecord.ID(UUID(2))), - .deleteRecord(CKRecord.ID(UUID(3))), + .deleteRecord(RemindersList.recordID(for: UUID(1))), + .deleteRecord(Reminder.recordID(for: UUID(1))), + .deleteRecord(Reminder.recordID(for: UUID(2))), + .deleteRecord(Reminder.recordID(for: UUID(3))), ]) } @@ -54,9 +54,9 @@ extension BaseCloudKitTests { } } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))), - .saveRecord(CKRecord.ID(UUID(2))), - .saveRecord(CKRecord.ID(UUID(3))), + .saveRecord(User.recordID(for: UUID(1))), + .saveRecord(RemindersList.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(3))), ]) try database.write { db in try User.find(UUID(1)).delete().execute(db) @@ -70,8 +70,8 @@ extension BaseCloudKitTests { ) } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .deleteRecord(CKRecord.ID(UUID(1))), - .saveRecord(CKRecord.ID(UUID(3))), + .deleteRecord(User.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(3))), ]) } @@ -86,10 +86,10 @@ extension BaseCloudKitTests { } } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))), - .saveRecord(CKRecord.ID(UUID(2))), - .saveRecord(CKRecord.ID(UUID(3))), - .saveRecord(CKRecord.ID(UUID(4))), + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(3))), + .saveRecord(Reminder.recordID(for: UUID(4))), ]) try database.write { db in try RemindersList.find(UUID(1)).update { $0.id = UUID(9) }.execute(db) @@ -105,10 +105,10 @@ extension BaseCloudKitTests { ) } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(9))), - .saveRecord(CKRecord.ID(UUID(2))), - .saveRecord(CKRecord.ID(UUID(3))), - .saveRecord(CKRecord.ID(UUID(4))), + .saveRecord(RemindersList.recordID(for: UUID(9))), + .saveRecord(Reminder.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(3))), + .saveRecord(Reminder.recordID(for: UUID(4))), ]) } @@ -122,9 +122,9 @@ extension BaseCloudKitTests { } } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))), - .saveRecord(CKRecord.ID(UUID(2))), - .saveRecord(CKRecord.ID(UUID(3))), + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(3))), ]) do { let error = #expect(throws: DatabaseError.self) { @@ -189,9 +189,9 @@ extension BaseCloudKitTests { } } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(CKRecord.ID(UUID(1))), - .saveRecord(CKRecord.ID(UUID(2))), - .saveRecord(CKRecord.ID(UUID(3))), + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(3))), ]) let error = #expect(throws: DatabaseError.self) { @@ -215,7 +215,6 @@ extension BaseCloudKitTests { ) } - withKnownIssue("We would prefer that no '.savedRecord's are appended.") { // NB: A '.savedRecord(UUID(9))' is being enqueued. privateSyncEngine.state.assertPendingRecordZoneChanges([]) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index bbabefad..924bc188 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -43,7 +43,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id", "new"."remindersListID" AS "foreignKey", datetime('subsec') + SELECT 'reminders', 'reminders' || ':' || "new"."id", 'remindersLists' || ':' || "new"."remindersListID" AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -54,7 +54,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id", "new"."remindersListID" AS "foreignKey", datetime('subsec') + SELECT 'reminders', 'reminders' || ':' || "new"."id", 'remindersLists' || ':' || "new"."remindersListID" AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -64,7 +64,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "reminders" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id"); + WHERE ("sqlitedata_icloud_metadata"."recordName" = 'reminders' || ':' || "old"."id"); END """, [6]: """ @@ -126,7 +126,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id", NULL AS "foreignKey", datetime('subsec') + SELECT 'remindersLists', 'remindersLists' || ':' || "new"."id", NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -137,7 +137,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id", NULL AS "foreignKey", datetime('subsec') + SELECT 'remindersLists', 'remindersLists' || ':' || "new"."id", NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -147,7 +147,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id"); + WHERE ("sqlitedata_icloud_metadata"."recordName" = 'remindersLists' || ':' || "old"."id"); END """, [15]: """ @@ -156,7 +156,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id", NULL AS "foreignKey", datetime('subsec') + SELECT 'users', 'users' || ':' || "new"."id", NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -167,7 +167,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id", NULL AS "foreignKey", datetime('subsec') + SELECT 'users', 'users' || ':' || "new"."id", NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -177,7 +177,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "users" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id"); + WHERE ("sqlitedata_icloud_metadata"."recordName" = 'users' || ':' || "old"."id"); END """, [18]: """ diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 62e4640a..3e116c48 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -3,15 +3,15 @@ import ConcurrencyExtras import CustomDump import SharingGRDBCore -extension CKRecord.ID { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - convenience init(_ id: UUID) { - self.init( - recordName: id.uuidString.lowercased(), - zoneID: SyncEngine.defaultZone.zoneID - ) - } -} +//extension CKRecord.ID { +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// convenience init(_ id: UUID) { +// self.init( +// recordName: id.uuidString.lowercased(), +// zoneID: SyncEngine.defaultZone.zoneID +// ) +// } +//} extension PrimaryKeyedTable { static func recordID(for id: UUID) -> CKRecord.ID { From 84a4e38656de21838c2399f8f34ea586b4642a1b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 12:54:05 -0700 Subject: [PATCH 155/581] clean up --- .../CloudKit/CloudKit+StructuredQueries.swift | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 887f2977..ab4081bb 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -111,38 +111,12 @@ extension CKRecord { "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" } -//extension PrimaryKeyedTable where TableColumns.PrimaryKey == UUID { -// static func find(recordID: CKRecord.ID) -> Where { -// let recordName = UUID(uuidString: recordID.recordName) -// if recordName == nil { -// reportIssue( -// """ -// 'recordName' ("\(recordID.recordName)") must be a UUID. -// """ -// ) -// } -// return Self.where { -// $0.primaryKey.eq(recordName ?? UUID()) -// } -// } -//} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { init?(record: CKRecord) { let recordName = RecordName(recordID: record.recordID) guard let recordName - else { - // TODO: is it ok to make this initializer failable? - return nil - } -// if recordName == nil { -// reportIssue( -// """ -// 'recordName' ("\(record.recordID.recordName)") must be a 'recordType' and UUID pair. -// """ -// ) -// } + else { return nil } self.init( recordType: record.recordType, recordName: recordName, From 23a616dacb5446fca0e829585a877dd6567d5e1f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 12:55:17 -0700 Subject: [PATCH 156/581] wip --- .../Internal/CloudKitTestHelpers.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 3e116c48..e5869f6e 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -3,16 +3,6 @@ import ConcurrencyExtras import CustomDump import SharingGRDBCore -//extension CKRecord.ID { -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// convenience init(_ id: UUID) { -// self.init( -// recordName: id.uuidString.lowercased(), -// zoneID: SyncEngine.defaultZone.zoneID -// ) -// } -//} - extension PrimaryKeyedTable { static func recordID(for id: UUID) -> CKRecord.ID { CKRecord.ID( From a67210860e2b77e659901d3dfeb2a5b8144ffd29 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 13:50:43 -0700 Subject: [PATCH 157/581] use id:table format --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Examples/Reminders/RemindersListForm.swift | 5 ++++- Examples/Reminders/RemindersLists.swift | 2 +- .../SharingGRDBCore/CloudKit/Logging.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 21 ++++++++++--------- .../CloudKit/SyncMetadata.swift | 10 ++++----- .../SharingGRDBCore/CloudKit/Triggers.swift | 2 +- 7 files changed, 25 insertions(+), 21 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bbd54c86..976201f4 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cfa986227a2051ca83eae9c181301c75e9fcd30d8f199a7ee1c7b269b548e192", + "originHash" : "b6534cfa47456b954c745e8e62752cccc8a947728cdc9341f6f64009bb32aef4", "pins" : [ { "identity" : "combine-schedulers", @@ -124,7 +124,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "temp-triggers", - "revision" : "bcac7860fcef9ca1da97d8aa96737ed90465a01d" + "revision" : "365ce8f75dd119bcb4788481fe0590c4c4aee63b" } }, { diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index ab2234df..7862fcee 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -124,13 +124,16 @@ struct RemindersListForm: View { .task { guard let remindersListID = remindersList.id else { return } - await withErrorReporting { + do { coverImageData = try await database.read { db in try RemindersListAsset .where { $0.remindersListID.eq(remindersListID) } .select(\.coverImage) .fetchOne(db) ?? nil } + } catch is CancellationError { + } catch { + reportIssue(error) } } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 751a4841..32895995 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -13,7 +13,7 @@ class RemindersListsModel { .group(by: \.id) .order(by: \.position) .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted } - .leftJoin(Metadata.all) { $0.id.eq($2.recordName) } + .leftJoin(SyncMetadata.all) { $0.recordName.eq($2.recordName) } .select { remindersList, reminder, metadata in ReminderListState.Columns( remindersCount: reminder.id.count(), diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index ede59ece..d2eb6829 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -39,7 +39,7 @@ extension Logger { let deletions = event.deletions.isEmpty ? "⚪️ No deletions" - : "✅ Zones deleted (\(event.deletions.count): " + : "✅ Zones deleted (\(event.deletions.count)): " + event.deletions .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } .sorted() diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d54944f0..0ab0db61 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -539,20 +539,21 @@ extension SyncEngine: CKSyncEngineDelegate { } for table in tables { withErrorReporting(.sqliteDataCloudKitFailure) { - let names = try database.read { db in - func open>(_: T.Type) throws -> [UUID] { + let recordNames = try database.read { db in + func open>(_: T.Type) throws -> [SyncMetadata.RecordName] { try T .select(\.primaryKey) .fetchAll(db) + .map { T.recordName(for: $0) } } return try open(table) } syncEngines.withValue { $0.private?.state.add( - pendingRecordZoneChanges: names.map { + pendingRecordZoneChanges: recordNames.map { .saveRecord( CKRecord.ID( - recordName: $0.uuidString, + recordName: $0.rawValue, zoneID: Self.defaultZone.zoneID ) ) @@ -641,7 +642,7 @@ extension SyncEngine: CKSyncEngineDelegate { reportIssue(""" Received 'recordName' in invalid format: \(recordID.recordName) - 'recordName' should be formatted as "tableName:uuid". + 'recordName' should be formatted as "uuid:tableName". """) continue } @@ -695,7 +696,7 @@ extension SyncEngine: CKSyncEngineDelegate { reportIssue(""" Attempted to delete record with invalid 'recordName': \(failedRecord.recordID.recordName) - 'recordName' should be formatted as "tableName:uuid". + 'recordName' should be formatted as "uuid:tableName". """) continue } @@ -755,7 +756,7 @@ extension SyncEngine: CKSyncEngineDelegate { reportIssue(""" Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) - 'recordName' should be formatted as "tableName:uuid". + 'recordName' should be formatted as "uuid:tableName". """) return } @@ -804,7 +805,7 @@ extension SyncEngine: CKSyncEngineDelegate { reportIssue(""" Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) - 'recordName' should be formatted as "tableName:uuid". + 'recordName' should be formatted as "uuid:tableName". """) return } @@ -892,7 +893,7 @@ extension SyncEngine: CKSyncEngineDelegate { reportIssue(""" Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) - 'recordName' should be formatted as "tableName:uuid". + 'recordName' should be formatted as "uuid:tableName". """) return } @@ -965,7 +966,7 @@ extension DatabaseFunction { reportIssue(""" Received 'recordName' in invalid format: \(recordName) - 'recordName' should be formatted as "tableName:uuid". + 'recordName' should be formatted as "uuid:tableName". """) return nil } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 67c3e7c7..3ddad8c8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -27,12 +27,12 @@ public struct SyncMetadata: Hashable, Sendable { else { return nil } - guard let id = UUID(uuidString: String(rawValue[rawValue.index(after: colonIndex)...])) + guard let id = UUID(uuidString: String(rawValue[rawValue.startIndex.. { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTableDefinition { - var recordName: some QueryExpression { - SQLQueryExpression("\(quote: QueryValue.tableName, delimiter: .text) || ':' || \(primaryKey)") + public var recordName: some QueryExpression { + SQLQueryExpression(" \(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 9545527c..0fffdef2 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -44,7 +44,7 @@ extension SyncMetadata { foreignKey: ForeignKey?, ) -> some StructuredQueriesCore.Statement { let foreignKey = foreignKey.map { - #"'\#($0.table)' || ':' || "new"."\#($0.from)""# + #""new"."\#($0.from)" || ':' || '\#($0.table)'"# } ?? "NULL" return insert { ($0.recordType, $0.recordName, $0.parentRecordName, $0.userModificationDate) From bb91f386f1ce7c41267c3ae202e4ce8f0b03f4c6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 13:52:02 -0700 Subject: [PATCH 158/581] fix tests --- .../CloudKitTests/TriggerTests.swift | 18 +++++++++--------- .../Internal/CloudKitTestHelpers.swift | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 924bc188..b09c7924 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -43,7 +43,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', 'reminders' || ':' || "new"."id", 'remindersLists' || ':' || "new"."remindersListID" AS "foreignKey", datetime('subsec') + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -54,7 +54,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', 'reminders' || ':' || "new"."id", 'remindersLists' || ':' || "new"."remindersListID" AS "foreignKey", datetime('subsec') + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -64,7 +64,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "reminders" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = 'reminders' || ':' || "old"."id"); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminders'); END """, [6]: """ @@ -126,7 +126,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', 'remindersLists' || ':' || "new"."id", NULL AS "foreignKey", datetime('subsec') + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -137,7 +137,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', 'remindersLists' || ':' || "new"."id", NULL AS "foreignKey", datetime('subsec') + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -147,7 +147,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = 'remindersLists' || ':' || "old"."id"); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); END """, [15]: """ @@ -156,7 +156,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', 'users' || ':' || "new"."id", NULL AS "foreignKey", datetime('subsec') + SELECT 'users', "new"."id" || ':' || 'users', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -167,7 +167,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', 'users' || ':' || "new"."id", NULL AS "foreignKey", datetime('subsec') + SELECT 'users', "new"."id" || ':' || 'users', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -177,7 +177,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "users" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = 'users' || ':' || "old"."id"); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'users'); END """, [18]: """ diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index e5869f6e..518401a7 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -6,7 +6,7 @@ import SharingGRDBCore extension PrimaryKeyedTable { static func recordID(for id: UUID) -> CKRecord.ID { CKRecord.ID( - recordName: "\(Self.tableName):\(id.uuidString)", + recordName: self.recordName(for: id).rawValue, zoneID: SyncEngine.defaultZone.zoneID ) } From a5f2088a6df6622cabd732a27c865cd41819db9f Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:54:40 -0700 Subject: [PATCH 159/581] Update Sources/SharingGRDBCore/CloudKit/Triggers.swift Co-authored-by: Stephen Celis --- Sources/SharingGRDBCore/CloudKit/Triggers.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 0fffdef2..533d22e6 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -30,7 +30,7 @@ extension PrimaryKeyedTable { ifNotExists: true, after: .delete { old in SyncMetadata - .where { $0.recordName.eq(old.recordName) } + .find(old.recordName) .delete() } ) From c408e7d1de937384e2a2df2eb3644fe07af873b0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 20:54:57 -0700 Subject: [PATCH 160/581] Allow sharing of records with at most one FK. --- .../CloudKit/CloudKitSharing.swift | 11 +- .../SharingGRDBCore/CloudKit/ForeignKey.swift | 4 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 60 ++--- .../Documentation.docc/Articles/CloudKit.md | 2 +- .../CloudKitTests/CloudKitTests.swift | 45 +++- .../CloudKitTests/ForeignKeyTests.swift | 103 ++------ .../CloudKitTests/SharingTests.swift | 38 ++- .../CloudKitTests/TriggerTests.swift | 228 ++++++++++++++---- .../Internal/BaseCloudKitTests.swift | 10 +- Tests/SharingGRDBTests/Internal/Schema.swift | 59 ++++- 10 files changed, 382 insertions(+), 178 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 809ab4e3..e2e39dff 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -15,7 +15,8 @@ public struct SharedRecord: Hashable, Identifiable, Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { - public struct CantShareRecordWithParent: Error {} + public struct UnrecognizedTable: Error {} + public struct RecordMustBeRoot: Error {} public struct NoCKRecordFound: Error {} public func share( @@ -23,9 +24,13 @@ extension SyncEngine { configure: @Sendable (CKShare) -> Void ) async throws -> SharedRecord where T.TableColumns.PrimaryKey == UUID { - guard foreignKeysByTableName[T.tableName]?.count(where: \.notnull) ?? 0 == 0 + guard let foreignKeys = foreignKeysByTableName[T.tableName] else { - throw CantShareRecordWithParent() + throw UnrecognizedTable() + } + guard foreignKeys.isEmpty + else { + throw RecordMustBeRoot() } let recordName = SyncMetadata.RecordName(record: record) diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index e1c68928..353e735b 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -83,7 +83,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onDeleteRestrict" - AFTER DELETE ON \(P.self) + BEFORE DELETE ON \(P.self) FOR EACH ROW BEGIN SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') FROM \(C.self) @@ -158,7 +158,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onUpdateRestrict" - AFTER UPDATE ON \(P.self) + BEFORE UPDATE ON \(P.self) FOR EACH ROW BEGIN SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') FROM \(C.self) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0ab0db61..48979968 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -337,8 +337,8 @@ extension PrimaryKeyedTable { db: Database ) throws { let foreignKey = - foreignKeysByTableName[tableName]?.count(where: \.notnull) == 1 - ? foreignKeysByTableName[tableName]?.first(where: \.notnull) + foreignKeysByTableName[tableName]?.count == 1 + ? foreignKeysByTableName[tableName]?.first : nil for trigger in metadataTriggers(foreignKey: foreignKey) { @@ -348,7 +348,7 @@ extension PrimaryKeyedTable { let foreignKeys = foreignKeysByTableName[tableName] ?? [] for foreignKey in foreignKeys { guard let parent = tablesByName[foreignKey.table] else { - reportIssue("") + reportIssue("TODO") continue } try foreignKey.createTriggers(Self.self, belongsTo: parent, db: db) @@ -1050,33 +1050,33 @@ private func validateSchema( ) throws { try database.read { db in for table in tables { - // TODO: write tests for this - let columnsWithUniqueConstraints = - try SQLQueryExpression( - """ - SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) - WHERE "unique" = 1 AND "origin" <> 'pk' - """, - as: String.self - ) - .fetchAll(db) - if !columnsWithUniqueConstraints.isEmpty { - throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) - } - - // TODO: write tests for this - let nonNullColumnsWithNoDefault = - try SQLQueryExpression( - """ - SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) - WHERE "notnull" = 1 AND "dflt_value" IS NULL - """, - as: String.self - ) - .fetchAll(db) - if !nonNullColumnsWithNoDefault.isEmpty { - throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) - } +// // TODO: write tests for this +// let columnsWithUniqueConstraints = +// try SQLQueryExpression( +// """ +// SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) +// WHERE "unique" = 1 AND "origin" <> 'pk' +// """, +// as: String.self +// ) +// .fetchAll(db) +// if !columnsWithUniqueConstraints.isEmpty { +// throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) +// } + +// // TODO: write tests for this +// let nonNullColumnsWithNoDefault = +// try SQLQueryExpression( +// """ +// SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) +// WHERE "notnull" = 1 AND "dflt_value" IS NULL +// """, +// as: String.self +// ) +// .fetchAll(db) +// if !nonNullColumnsWithNoDefault.isEmpty { +// throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) +// } } } } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 3dfd3b6a..83c68cfb 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -382,7 +382,7 @@ in the list? For these reasons it is not possible to share non-root records, like reminders. Instead, you can share root records, like reminders lists. If you do invoke ``SyncEngine/share(record:configure:)`` -with a non-root record, a ``SyncEngine/CantShareRecordWithParent`` error will be thrown. +with a non-root record, a ``SyncEngine/RecordMustBeRoot`` error will be thrown. > Note: A reminder can still be shared as an association to a shared reminders list, as discussed > [in the next section](). However, a single diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index e9d51345..b72dc051 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -21,7 +21,7 @@ extension BaseCloudKitTests { tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '' ) STRICT """ @@ -30,7 +30,7 @@ extension BaseCloudKitTests { tableName: "users", schema: """ CREATE TABLE "users" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "name" TEXT NOT NULL DEFAULT '', "parentUserID" TEXT, @@ -42,17 +42,50 @@ extension BaseCloudKitTests { tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "assignedUserID" TEXT, "title" TEXT NOT NULL DEFAULT '', "parentReminderID" TEXT, - "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + "remindersListID" TEXT NOT NULL, - FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, - FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ + ), + [3]: RecordType( + tableName: "parents", + schema: """ + CREATE TABLE "parents"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()) + ) STRICT + """ + ), + [4]: RecordType( + tableName: "childWithOnDeleteRestricts", + schema: """ + CREATE TABLE "childWithOnDeleteRestricts"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT + ) STRICT + """ + ), + [5]: RecordType( + tableName: "childWithOnDeleteSetNulls", + schema: """ + CREATE TABLE "childWithOnDeleteSetNulls"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + ) STRICT + """ + ), + [6]: RecordType( + tableName: "childWithOnDeleteSetDefaults", + schema: """ + CREATE TABLE "childWithOnDeleteSetDefaults"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', + "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + ) STRICT + """ ) ] """# diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 051c3de5..2f1f386e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -43,35 +43,28 @@ extension BaseCloudKitTests { @Test func deleteSetNull() throws { try database.write { db in try db.seed { - User(id: UUID(1), name: "Blob") - RemindersList(id: UUID(2), title: "Personal") - Reminder( - id: UUID(3), - assignedUserID: UUID(1), - title: "Groceries", - remindersListID: UUID(2) - ) + Parent(id: UUID(1)) + ChildWithOnDeleteSetNull(id: UUID(1), parentID: UUID(1)) } } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(User.recordID(for: UUID(1))), - .saveRecord(RemindersList.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(3))), + .saveRecord(Parent.recordID(for: UUID(1))), + .saveRecord(ChildWithOnDeleteSetNull.recordID(for: UUID(1))), ]) try database.write { db in - try User.find(UUID(1)).delete().execute(db) + try Parent.find(UUID(1)).delete().execute(db) } try database.read { db in try expectNoDifference( - Reminder.all.fetchAll(db), + ChildWithOnDeleteSetNull.all.fetchAll(db), [ - Reminder(id: UUID(3), assignedUserID: nil, title: "Groceries", remindersListID: UUID(2)) + ChildWithOnDeleteSetNull(id: UUID(1), parentID: nil) ] ) } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .deleteRecord(User.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(3))), + .deleteRecord(Parent.recordID(for: UUID(1))), + .saveRecord(ChildWithOnDeleteSetNull.recordID(for: UUID(1))), ]) } @@ -116,109 +109,59 @@ extension BaseCloudKitTests { @Test func deleteRestrict() throws { try database.write { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)) + Parent(id: UUID(1)) + ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) } } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(3))), + .saveRecord(Parent.recordID(for: UUID(1))), + .saveRecord(ChildWithOnDeleteRestrict.recordID(for: UUID(1))), ]) do { let error = #expect(throws: DatabaseError.self) { try self.database.write { db in - try Reminder.find(UUID(2)).delete().execute(db) + try Parent.find(UUID(1)).delete().execute(db) } } #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) try database.read { db in try expectNoDifference( - Reminder.all.fetchAll(db), + ChildWithOnDeleteRestrict.all.fetchAll(db), [ - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)), - Reminder( - id: UUID(3), - title: "Milk", - parentReminderID: UUID(2), - remindersListID: UUID(1) - ), + ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) ] ) } } - - do { - let error = #expect(throws: DatabaseError.self) { - try self.database.write { db in - try RemindersList.find(UUID(1)).delete().execute(db) - } - } - #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try database.read { db in - try expectNoDifference( - Reminder.all.fetchAll(db), - [ - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)), - Reminder( - id: UUID(3), - title: "Milk", - parentReminderID: UUID(2), - remindersListID: UUID(1) - ), - ] - ) - } - try database.read { db in - try expectNoDifference( - RemindersList.all.fetchAll(db), - [RemindersList(id: UUID(1), title: "Personal")] - ) - } - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func updateRestrict() throws { try database.write { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Milk", parentReminderID: UUID(2), remindersListID: UUID(1)) + Parent(id: UUID(1)) + ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) } } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(3))), + .saveRecord(Parent.recordID(for: UUID(1))), + .saveRecord(ChildWithOnDeleteRestrict.recordID(for: UUID(1))), ]) let error = #expect(throws: DatabaseError.self) { try self.database.write { db in - try Reminder.find(UUID(2)).update { $0.id = UUID(9) }.execute(db) + try Parent.find(UUID(1)).update { $0.id = UUID(2) }.execute(db) } } #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) try database.read { db in try expectNoDifference( - Reminder.all.fetchAll(db), + ChildWithOnDeleteRestrict.all.fetchAll(db), [ - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)), - Reminder( - id: UUID(3), - title: "Milk", - parentReminderID: UUID(2), - remindersListID: UUID(1) - ), + ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) ] ) } - - withKnownIssue("We would prefer that no '.savedRecord's are appended.") { - // NB: A '.savedRecord(UUID(9))' is being enqueued. - privateSyncEngine.state.assertPendingRecordZoneChanges([]) - } } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 289775d9..16ce568d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -9,10 +9,44 @@ import Testing extension BaseCloudKitTests { final class SharingTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func basics() { - + @Test func shareNonRootRecord() async throws { + let reminder = Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) + let user = User(id: UUID(1)) + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + reminder + user + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(1))), + .saveRecord(User.recordID(for: UUID(1))), + ]) + + await #expect(throws: SyncEngine.RecordMustBeRoot.self) { + _ = try await self.syncEngine.share(record: reminder, configure: { _ in }) + } + await #expect(throws: SyncEngine.RecordMustBeRoot.self) { + _ = try await self.syncEngine.share(record: user, configure: { _ in }) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareUnrecognizedTable() async throws { + await #expect(throws: SyncEngine.UnrecognizedTable.self) { + _ = try await self.syncEngine.share( + record: NonSyncedTable(id: UUID()), + configure: { _ in } + ) + } } } } // TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list + +@Table fileprivate struct NonSyncedTable { + let id: UUID +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index b09c7924..0bbe6e25 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -85,42 +85,6 @@ extension BaseCloudKitTests { END """, [8]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onDeleteRestrict" - AFTER DELETE ON "reminders" - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "reminders" - WHERE "parentReminderID" = "old"."id"; - END - """, - [9]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_reminders_onUpdateRestrict" - AFTER UPDATE ON "reminders" - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "reminders" - WHERE "parentReminderID" = "old"."id"; - END - """, - [10]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_users_onDeleteSetNull" - AFTER DELETE ON "users" - FOR EACH ROW BEGIN - UPDATE "reminders" - SET "assignedUserID" = NULL - WHERE "assignedUserID" = "old"."id"; - END - """, - [11]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_users_onUpdateCascade" - AFTER UPDATE ON "users" - FOR EACH ROW BEGIN - UPDATE "reminders" - SET "assignedUserID" = "new"."id" - WHERE "assignedUserID" = "old"."id"; - END - """, - [12]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -131,7 +95,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [13]: """ + [9]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -142,7 +106,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [14]: """ + [10]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -150,29 +114,29 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); END """, - [15]: """ + [11]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_users" AFTER INSERT ON "users" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id" || ':' || 'users', NULL AS "foreignKey", datetime('subsec') + SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [16]: """ + [12]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_users" AFTER UPDATE ON "users" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id" || ':' || 'users', NULL AS "foreignKey", datetime('subsec') + SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [17]: """ + [13]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_users" AFTER DELETE ON "users" FOR EACH ROW BEGIN @@ -180,7 +144,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'users'); END """, - [18]: """ + [14]: """ CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" AFTER DELETE ON "users" FOR EACH ROW BEGIN @@ -189,7 +153,7 @@ extension BaseCloudKitTests { WHERE "parentUserID" = "old"."id"; END """, - [19]: """ + [15]: """ CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" AFTER UPDATE ON "users" FOR EACH ROW BEGIN @@ -197,6 +161,180 @@ extension BaseCloudKitTests { SET "parentUserID" = "new"."id" WHERE "parentUserID" = "old"."id"; END + """, + [16]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" + AFTER INSERT ON "parents" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [17]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" + AFTER UPDATE ON "parents" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [18]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" + AFTER DELETE ON "parents" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'parents'); + END + """, + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" + AFTER INSERT ON "childWithOnDeleteRestricts" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [20]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" + AFTER UPDATE ON "childWithOnDeleteRestricts" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [21]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteRestricts" + AFTER DELETE ON "childWithOnDeleteRestricts" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteRestricts'); + END + """, + [22]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" + BEFORE DELETE ON "parents" + FOR EACH ROW BEGIN + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "childWithOnDeleteRestricts" + WHERE "parentID" = "old"."id"; + END + """, + [23]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" + BEFORE UPDATE ON "parents" + FOR EACH ROW BEGIN + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "childWithOnDeleteRestricts" + WHERE "parentID" = "old"."id"; + END + """, + [24]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" + AFTER INSERT ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [25]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" + AFTER UPDATE ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [26]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" + AFTER DELETE ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetNulls'); + END + """, + [27]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" + AFTER DELETE ON "parents" + FOR EACH ROW BEGIN + UPDATE "childWithOnDeleteSetNulls" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; + END + """, + [28]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" + AFTER UPDATE ON "parents" + FOR EACH ROW BEGIN + UPDATE "childWithOnDeleteSetNulls" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; + END + """, + [29]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" + AFTER INSERT ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [30]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" + AFTER UPDATE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [31]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" + AFTER DELETE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetDefaults'); + END + """, + [32]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" + AFTER DELETE ON "parents" + FOR EACH ROW BEGIN + UPDATE "childWithOnDeleteSetDefaults" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; + END + """, + [33]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" + AFTER UPDATE ON "parents" + FOR EACH ROW BEGIN + UPDATE "childWithOnDeleteSetDefaults" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; + END """ ] """# diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 2b793c6e..4476236d 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -40,7 +40,15 @@ class BaseCloudKitTests: @unchecked Sendable { metadatabaseURL: URL.temporaryDirectory.appending( path: "metadatabase.\(UUID().uuidString).sqlite" ), - tables: [Reminder.self, RemindersList.self, User.self] + tables: [ + Reminder.self, + RemindersList.self, + User.self, + Parent.self, + ChildWithOnDeleteRestrict.self, + ChildWithOnDeleteSetNull.self, + ChildWithOnDeleteSetDefault.self, + ] ) try await Task.sleep(for: .seconds(0.1)) privateSyncEngine.assertFetchChangesScopes([.all]) diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 6df98b55..36145b8b 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -3,9 +3,9 @@ import SharingGRDB @Table struct Reminder: Equatable, Identifiable { let id: UUID - var assignedUserID: User.ID? +// var assignedUserID: User.ID? var title = "" - var parentReminderID: ID? +// var parentReminderID: ID? var remindersListID: RemindersList.ID } @Table struct RemindersList: Equatable, Identifiable { @@ -18,6 +18,22 @@ import SharingGRDB var parentUserID: User.ID? } +@Table struct Parent: Equatable, Identifiable { + let id: UUID +} +@Table struct ChildWithOnDeleteRestrict: Equatable, Identifiable { + let id: UUID + let parentID: Parent.ID +} +@Table struct ChildWithOnDeleteSetNull: Equatable, Identifiable { + let id: UUID + let parentID: Parent.ID? +} +@Table struct ChildWithOnDeleteSetDefault: Equatable, Identifiable { + let id: UUID + let parentID: Parent.ID +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func database() throws -> DatabasePool { var configuration = Configuration() @@ -33,7 +49,7 @@ func database() throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersLists" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '' ) STRICT """ @@ -42,7 +58,7 @@ func database() throws -> DatabasePool { try #sql( """ CREATE TABLE "users" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "name" TEXT NOT NULL DEFAULT '', "parentUserID" TEXT, @@ -54,19 +70,46 @@ func database() throws -> DatabasePool { try #sql( """ CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "assignedUserID" TEXT, "title" TEXT NOT NULL DEFAULT '', "parentReminderID" TEXT, - "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + "remindersListID" TEXT NOT NULL, - FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, - FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ ) .execute(db) + try #sql(""" + CREATE TABLE "parents"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()) + ) STRICT + """) + .execute(db) + try #sql(""" + CREATE TABLE "childWithOnDeleteRestricts"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT + ) STRICT + """) + .execute(db) + try #sql(""" + CREATE TABLE "childWithOnDeleteSetNulls"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + ) STRICT + """) + .execute(db) + try #sql(""" + CREATE TABLE "childWithOnDeleteSetDefaults"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', + "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + ) STRICT + """) + .execute(db) + + } return database } From c0f384c285a3681f906ca343bb7933b8cc6caa4c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 21:02:04 -0700 Subject: [PATCH 161/581] rename foreignKey to parentForeignKey" --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 6 +++--- .../SharingGRDBCore/CloudKit/Triggers.swift | 20 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 48979968..8d7733b3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -336,12 +336,12 @@ extension PrimaryKeyedTable { tablesByName: [String: any PrimaryKeyedTable.Type], db: Database ) throws { - let foreignKey = + let parentForeignKey = foreignKeysByTableName[tableName]?.count == 1 ? foreignKeysByTableName[tableName]?.first : nil - for trigger in metadataTriggers(foreignKey: foreignKey) { + for trigger in metadataTriggers(parentForeignKey: parentForeignKey) { try trigger.execute(db) } @@ -365,7 +365,7 @@ extension PrimaryKeyedTable { try foreignKey.dropTriggers(for: Self.self, db: db) } - for trigger in metadataTriggers(foreignKey: nil).reversed() { + for trigger in metadataTriggers(parentForeignKey: nil).reversed() { try trigger.drop().execute(db) } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 533d22e6..1e5a4fc4 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -2,25 +2,25 @@ import Foundation @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTable { - static func metadataTriggers(foreignKey: ForeignKey?) -> [TemporaryTrigger] { + static func metadataTriggers(parentForeignKey: ForeignKey?) -> [TemporaryTrigger] { [ - afterInsert(foreignKey: foreignKey), - afterUpdate(foreignKey: foreignKey), + afterInsert(parentForeignKey: parentForeignKey), + afterUpdate(parentForeignKey: parentForeignKey), afterDelete, ] } - fileprivate static func afterInsert(foreignKey: ForeignKey?) -> TemporaryTrigger { + fileprivate static func afterInsert(parentForeignKey: ForeignKey?) -> TemporaryTrigger { createTemporaryTrigger( "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", - after: .insert { new in SyncMetadata.insert(new: new, foreignKey: foreignKey) } + after: .insert { new in SyncMetadata.insert(new: new, parentForeignKey: parentForeignKey) } ) } - fileprivate static func afterUpdate(foreignKey: ForeignKey?) -> TemporaryTrigger { + fileprivate static func afterUpdate(parentForeignKey: ForeignKey?) -> TemporaryTrigger { createTemporaryTrigger( "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", - after: .update { _, new in SyncMetadata.insert(new: new, foreignKey: foreignKey) } + after: .update { _, new in SyncMetadata.insert(new: new, parentForeignKey: parentForeignKey) } ) } @@ -41,9 +41,9 @@ extension PrimaryKeyedTable { extension SyncMetadata { fileprivate static func insert>( new: TemporaryTrigger.Operation.New, - foreignKey: ForeignKey?, + parentForeignKey: ForeignKey?, ) -> some StructuredQueriesCore.Statement { - let foreignKey = foreignKey.map { + let parentForeignKey = parentForeignKey.map { #""new"."\#($0.from)" || ':' || '\#($0.table)'"# } ?? "NULL" return insert { @@ -52,7 +52,7 @@ extension SyncMetadata { Values( T.tableName, new.recordName, - SQLQueryExpression(#"\#(raw: foreignKey) AS "foreignKey""#), + SQLQueryExpression(#"\#(raw: parentForeignKey) AS "foreignKey""#), .datetime("subsec") ) } onConflict: { From aae2b045f3e558f0157748a74ac7c158f13ee531 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 20 Jun 2025 21:06:30 -0700 Subject: [PATCH 162/581] wip --- Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift | 2 -- Tests/SharingGRDBTests/Internal/Schema.swift | 4 ---- 2 files changed, 6 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index b72dc051..eead57da 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -43,9 +43,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "reminders" ( "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), - "assignedUserID" TEXT, "title" TEXT NOT NULL DEFAULT '', - "parentReminderID" TEXT, "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 36145b8b..d29cf097 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -3,9 +3,7 @@ import SharingGRDB @Table struct Reminder: Equatable, Identifiable { let id: UUID -// var assignedUserID: User.ID? var title = "" -// var parentReminderID: ID? var remindersListID: RemindersList.ID } @Table struct RemindersList: Equatable, Identifiable { @@ -71,9 +69,7 @@ func database() throws -> DatabasePool { """ CREATE TABLE "reminders" ( "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), - "assignedUserID" TEXT, "title" TEXT NOT NULL DEFAULT '', - "parentReminderID" TEXT, "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE From b103b5e9965b64960cff3233092181a47db251c8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 08:51:11 -0700 Subject: [PATCH 163/581] Support private, unshared tables. --- .../CloudKit/CloudKitSharing.swift | 15 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 181 +++++----- .../CloudKitTests/TriggerTests.swift | 308 ++++++++++-------- .../Internal/BaseCloudKitTests.swift | 3 + Tests/SharingGRDBTests/Internal/Schema.swift | 15 + 5 files changed, 293 insertions(+), 229 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index e2e39dff..6628eb22 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -18,20 +18,19 @@ extension SyncEngine { public struct UnrecognizedTable: Error {} public struct RecordMustBeRoot: Error {} public struct NoCKRecordFound: Error {} + public struct PrivateRootRecord: Error {} public func share( record: T, configure: @Sendable (CKShare) -> Void ) async throws -> SharedRecord where T.TableColumns.PrimaryKey == UUID { + guard !privateTables.contains(where: { T.self == $0 }) + else { throw PrivateRootRecord() } guard let foreignKeys = foreignKeysByTableName[T.tableName] - else { - throw UnrecognizedTable() - } + else { throw UnrecognizedTable() } guard foreignKeys.isEmpty - else { - throw RecordMustBeRoot() - } + else { throw RecordMustBeRoot() } let recordName = SyncMetadata.RecordName(record: record) let metadata = @@ -42,9 +41,7 @@ extension SyncEngine { } ?? nil guard let metadata - else { - throw NoCKRecordFound() - } + else { throw NoCKRecordFound() } let rootRecord = metadata.lastKnownServerRecord diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 8d7733b3..7d2c48e4 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -12,6 +12,7 @@ public final class SyncEngine: Sendable { let logger: Logger let metadatabase: any DatabaseReader let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + let privateTables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] let foreignKeysByTableName: [String: [ForeignKey]] let syncEngines = LockIsolated(SyncEngines()) @@ -24,7 +25,8 @@ public final class SyncEngine: Sendable { container: CKContainer, database: any DatabaseWriter, logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), - tables: [any PrimaryKeyedTable.Type] + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { try self.init( container: container, @@ -53,7 +55,8 @@ public final class SyncEngine: Sendable { database: database, logger: logger, metadatabaseURL: URL.metadatabase(containerIdentifier: container.containerIdentifier), - tables: tables + tables: tables, + privateTables: privateTables ) } @@ -62,14 +65,16 @@ public final class SyncEngine: Sendable { sharedSyncEngine: any SyncEngineProtocol, database: any DatabaseWriter, metadatabaseURL: URL, - tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { try self.init( defaultSyncEngines: { _, _ in (privateSyncEngine, sharedSyncEngine) }, database: database, logger: Logger(.disabled), metadatabaseURL: metadatabaseURL, - tables: tables + tables: tables, + privateTables: privateTables ) } @@ -82,7 +87,8 @@ public final class SyncEngine: Sendable { database: any DatabaseWriter, logger: Logger, metadatabaseURL: URL, - tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { try validateSchema(tables: tables, database: database) // TODO: Explain why / link to documentation? @@ -97,7 +103,8 @@ public final class SyncEngine: Sendable { self.database = database self.logger = logger self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) - self.tables = tables + self.tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)).map(\.type) + self.privateTables = privateTables self.tablesByName = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = Dictionary( uniqueKeysWithValues: try database.read { db in @@ -393,7 +400,7 @@ extension SyncEngine: CKSyncEngineDelegate { case .sentRecordZoneChanges(let event): handleSentRecordZoneChanges(event, syncEngine: syncEngine) case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, - .didFetchChanges, .willSendChanges, .didSendChanges: + .didFetchChanges, .willSendChanges, .didSendChanges: break @unknown default: break @@ -422,57 +429,57 @@ extension SyncEngine: CKSyncEngineDelegate { changes += keyValue.value } - #if DEBUG - struct State { - var missingTables: [CKRecord.ID] = [] - var missingRecords: [CKRecord.ID] = [] - var sentRecords: [CKRecord.ID] = [] - } - let state = LockIsolated(State()) - defer { - let state = state.withValue(\.self) - let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - logger.debug( +#if DEBUG + struct State { + var missingTables: [CKRecord.ID] = [] + var missingRecords: [CKRecord.ID] = [] + var sentRecords: [CKRecord.ID] = [] + } + let state = LockIsolated(State()) + defer { + let state = state.withValue(\.self) + let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + logger.debug( """ [\(syncEngine.scope.label)] nextRecordZoneChangeBatch: \(context.reason) \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") """ - ) - } - #endif + ) + } +#endif let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in - #if DEBUG - var missingTable: CKRecord.ID? - var missingRecord: CKRecord.ID? - var sentRecord: CKRecord.ID? - defer { - state.withValue { [missingTable, missingRecord, sentRecord] in - if let missingTable { $0.missingTables.append(missingTable) } - if let missingRecord { $0.missingRecords.append(missingRecord) } - if let sentRecord { $0.sentRecords.append(sentRecord) } - } +#if DEBUG + var missingTable: CKRecord.ID? + var missingRecord: CKRecord.ID? + var sentRecord: CKRecord.ID? + defer { + state.withValue { [missingTable, missingRecord, sentRecord] in + if let missingTable { $0.missingTables.append(missingTable) } + if let missingRecord { $0.missingRecords.append(missingRecord) } + if let sentRecord { $0.sentRecords.append(sentRecord) } } - #endif + } +#endif guard let recordName = SyncMetadata.RecordName(recordID: recordID), @@ -490,12 +497,12 @@ extension SyncEngine: CKSyncEngineDelegate { } func open>(_: T.Type) async -> CKRecord? { let row = - withErrorReporting { - try database.read { db in - try T.find(recordName.id).fetchOne(db) - } + withErrorReporting { + try database.read { db in + try T.find(recordName.id).fetchOne(db) } - ?? nil + } + ?? nil guard let row else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) @@ -504,13 +511,16 @@ extension SyncEngine: CKSyncEngineDelegate { } let record = - metadata.lastKnownServerRecord - ?? CKRecord( - recordType: metadata.recordType, - recordID: recordID - ) - record.parent = metadata.parentRecordName.map { parentRecordName in - CKRecord.Reference( + metadata.lastKnownServerRecord + ?? CKRecord( + recordType: metadata.recordType, + recordID: recordID + ) + record.parent = metadata.parentRecordName.flatMap { parentRecordName in + guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) + else { return nil } + + return CKRecord.Reference( recordID: CKRecord.ID( recordName: parentRecordName.rawValue, zoneID: record.recordID.zoneID @@ -589,26 +599,25 @@ extension SyncEngine: CKSyncEngineDelegate { } private func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { - // TODO: Come back to this once we have zoneName in the metadata table. - // $isUpdatingWithServerRecord.withValue(true) { - // withErrorReporting(.sqliteDataCloudKitFailure) { - // try database.write { db in - // for deletion in event.deletions { - // if let table = tablesByName[deletion.zoneID.zoneName] { - // func open(_: T.Type) { - // withErrorReporting(.sqliteDataCloudKitFailure) { - // try T.delete().execute(db) - // } - // } - // open(table) - // } - // } - // - // // TODO: Deal with modifications? - // _ = event.modifications - // } - // } - // } + // TODO: How to handle this? + $isUpdatingWithServerRecord.withValue(true) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + for deletion in event.deletions { + // if let table = tablesByName[deletion.zoneID.zoneName] { + // func open(_: T.Type) { + // withErrorReporting(.sqliteDataCloudKitFailure) { + // try T.delete().execute(db) + // } + // } + // open(table) + } + } + + // TODO: Deal with modifications? + _ = event.modifications + } + } } package func handleFetchedRecordZoneChanges( @@ -1099,3 +1108,13 @@ public struct NonNullColumnMustHaveDefault: Error { """ } } + +private struct HashablePrimaryKeyedTableType: Hashable { + let type: any PrimaryKeyedTable.Type + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(type)) + } + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.type == rhs.type + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 0bbe6e25..a9f8d6c4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -38,302 +38,332 @@ extension BaseCloudKitTests { END """, [3]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" - AFTER INSERT ON "reminders" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" + AFTER INSERT ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [4]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" - AFTER UPDATE ON "reminders" + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" + AFTER UPDATE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [5]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" - AFTER DELETE ON "reminders" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteRestricts" + AFTER DELETE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminders'); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteRestricts'); END """, [6]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" - AFTER DELETE ON "remindersLists" + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" + BEFORE DELETE ON "parents" FOR EACH ROW BEGIN - DELETE FROM "reminders" - WHERE "remindersListID" = "old"."id"; + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "childWithOnDeleteRestricts" + WHERE "parentID" = "old"."id"; END """, [7]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" - AFTER UPDATE ON "remindersLists" + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" + BEFORE UPDATE ON "parents" FOR EACH ROW BEGIN - UPDATE "reminders" - SET "remindersListID" = "new"."id" - WHERE "remindersListID" = "old"."id"; + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "childWithOnDeleteRestricts" + WHERE "parentID" = "old"."id"; END """, [8]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" - AFTER INSERT ON "remindersLists" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" + AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [9]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" - AFTER UPDATE ON "remindersLists" + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" + AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [10]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" - AFTER DELETE ON "remindersLists" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" + AFTER DELETE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetNulls'); END """, [11]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_users" - AFTER INSERT ON "users" + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" + AFTER DELETE ON "parents" + FOR EACH ROW BEGIN + UPDATE "childWithOnDeleteSetNulls" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; + END + """, + [12]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" + AFTER UPDATE ON "parents" + FOR EACH ROW BEGIN + UPDATE "childWithOnDeleteSetNulls" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; + END + """, + [13]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" + AFTER INSERT ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [12]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_users" - AFTER UPDATE ON "users" + [14]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" + AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [13]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_users" - AFTER DELETE ON "users" + [15]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" + AFTER DELETE ON "reminders" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'users'); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminders'); END """, - [14]: """ - CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" - AFTER DELETE ON "users" + [16]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" + AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN - UPDATE "users" - SET "parentUserID" = NULL - WHERE "parentUserID" = "old"."id"; + DELETE FROM "reminders" + WHERE "remindersListID" = "old"."id"; END """, - [15]: """ - CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" - AFTER UPDATE ON "users" + [17]: """ + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - UPDATE "users" - SET "parentUserID" = "new"."id" - WHERE "parentUserID" = "old"."id"; + UPDATE "reminders" + SET "remindersListID" = "new"."id" + WHERE "remindersListID" = "old"."id"; END """, - [16]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" - AFTER INSERT ON "parents" + [18]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" + AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [17]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" - AFTER UPDATE ON "parents" + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" + AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [18]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" - AFTER DELETE ON "parents" + [20]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" + AFTER DELETE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'parents'); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetDefaults'); END """, - [19]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" - AFTER INSERT ON "childWithOnDeleteRestricts" + [21]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" + AFTER DELETE ON "parents" + FOR EACH ROW BEGIN + UPDATE "childWithOnDeleteSetDefaults" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; + END + """, + [22]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" + AFTER UPDATE ON "parents" + FOR EACH ROW BEGIN + UPDATE "childWithOnDeleteSetDefaults" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; + END + """, + [23]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" + AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [20]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" - AFTER UPDATE ON "childWithOnDeleteRestricts" + [24]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" + AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [21]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteRestricts" - AFTER DELETE ON "childWithOnDeleteRestricts" + [25]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" + AFTER DELETE ON "remindersListPrivates" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteRestricts'); - END - """, - [22]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" - BEFORE DELETE ON "parents" - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "childWithOnDeleteRestricts" - WHERE "parentID" = "old"."id"; - END - """, - [23]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" - BEFORE UPDATE ON "parents" - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "childWithOnDeleteRestricts" - WHERE "parentID" = "old"."id"; + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListPrivates'); END """, - [24]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" - AFTER INSERT ON "childWithOnDeleteSetNulls" + [26]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_users" + AFTER INSERT ON "users" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [25]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" - AFTER UPDATE ON "childWithOnDeleteSetNulls" + [27]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_users" + AFTER UPDATE ON "users" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [26]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" - AFTER DELETE ON "childWithOnDeleteSetNulls" + [28]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_users" + AFTER DELETE ON "users" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetNulls'); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'users'); END """, - [27]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" - AFTER DELETE ON "parents" + [29]: """ + CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" + AFTER DELETE ON "users" FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetNulls" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; + UPDATE "users" + SET "parentUserID" = NULL + WHERE "parentUserID" = "old"."id"; END """, - [28]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" - AFTER UPDATE ON "parents" + [30]: """ + CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" + AFTER UPDATE ON "users" FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetNulls" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; + UPDATE "users" + SET "parentUserID" = "new"."id" + WHERE "parentUserID" = "old"."id"; END """, - [29]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" - AFTER INSERT ON "childWithOnDeleteSetDefaults" + [31]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" + AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [30]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" - AFTER UPDATE ON "childWithOnDeleteSetDefaults" + [32]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [31]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" - AFTER DELETE ON "childWithOnDeleteSetDefaults" + [33]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" + AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetDefaults'); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); END """, - [32]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" - AFTER DELETE ON "parents" + [34]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" + AFTER INSERT ON "parents" FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetDefaults" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [33]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" + [35]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetDefaults" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [36]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" + AFTER DELETE ON "parents" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'parents'); END """ ] diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 4476236d..1113b385 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -48,6 +48,9 @@ class BaseCloudKitTests: @unchecked Sendable { ChildWithOnDeleteRestrict.self, ChildWithOnDeleteSetNull.self, ChildWithOnDeleteSetDefault.self, + ], + privateTables: [ + RemindersListPrivate.self ] ) try await Task.sleep(for: .seconds(0.1)) diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index d29cf097..7645ed8c 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -10,6 +10,11 @@ import SharingGRDB let id: UUID var title = "" } +@Table struct RemindersListPrivate: Equatable, Identifiable { + let id: UUID + var position = 0 + var remindersListID: RemindersList.ID +} @Table struct User: Equatable, Identifiable { let id: UUID var name = "" @@ -53,6 +58,16 @@ func database() throws -> DatabasePool { """ ) .execute(db) + try #sql( + """ + CREATE TABLE "remindersListPrivates" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "position" INTEGER NOT NULL DEFAULT 0, + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) try #sql( """ CREATE TABLE "users" ( From 8baccc48e17e3f1570132d1dafe54904dc56f506 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 09:01:45 -0700 Subject: [PATCH 164/581] wip --- .../CloudKitTests/SharingTests.swift | 22 +- .../CloudKitTests/TriggerTests.swift | 332 +++++++++--------- 2 files changed, 187 insertions(+), 167 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 16ce568d..b0baaf9e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -42,10 +42,30 @@ extension BaseCloudKitTests { ) } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sharePrivateTable() async throws { + await #expect(throws: SyncEngine.PrivateRootRecord.self) { + _ = try await self.syncEngine.share( + record: RemindersListPrivate(id: UUID(1), remindersListID: UUID(1)), + configure: { _ in } + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareRecordBeforeSync() async throws { + await #expect(throws: SyncEngine.NoCKRecordFound.self) { + _ = try await self.syncEngine.share( + record: RemindersList(id: UUID(1)), + configure: { _ in } + ) + } + } } } - // TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list +// TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list @Table fileprivate struct NonSyncedTable { let id: UUID diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index a9f8d6c4..2c4b516f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -11,230 +11,235 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func triggers() async throws { let triggersAfterSetUp = try await database.write { db in - try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) + try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { #""" [ [0]: """ - CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" - AFTER INSERT ON "sqlitedata_icloud_metadata" + CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" + AFTER DELETE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName"); + SELECT sqlitedata_icloud_didDelete("old"."recordName"); END """, [1]: """ - CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" - AFTER UPDATE ON "sqlitedata_icloud_metadata" + CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" + AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName"); END """, [2]: """ - CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" - AFTER DELETE ON "sqlitedata_icloud_metadata" + CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" + AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN - SELECT sqlitedata_icloud_didDelete("old"."recordName"); + SELECT sqlitedata_icloud_didUpdate("new"."recordName"); END """, [3]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" - AFTER INSERT ON "childWithOnDeleteRestricts" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteRestricts" + AFTER DELETE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteRestricts'); END """, [4]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" - AFTER UPDATE ON "childWithOnDeleteRestricts" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" + AFTER DELETE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetDefaults'); END """, [5]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteRestricts" - AFTER DELETE ON "childWithOnDeleteRestricts" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" + AFTER DELETE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteRestricts'); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetNulls'); END """, [6]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" - BEFORE DELETE ON "parents" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" + AFTER DELETE ON "parents" FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "childWithOnDeleteRestricts" - WHERE "parentID" = "old"."id"; + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'parents'); END """, [7]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" - BEFORE UPDATE ON "parents" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" + AFTER DELETE ON "reminders" FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "childWithOnDeleteRestricts" - WHERE "parentID" = "old"."id"; + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminders'); END """, [8]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" - AFTER INSERT ON "childWithOnDeleteSetNulls" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" + AFTER DELETE ON "remindersListPrivates" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListPrivates'); END """, [9]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" - AFTER UPDATE ON "childWithOnDeleteSetNulls" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" + AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); END """, [10]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" - AFTER DELETE ON "childWithOnDeleteSetNulls" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_users" + AFTER DELETE ON "users" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetNulls'); + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'users'); END """, [11]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" - AFTER DELETE ON "parents" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" + AFTER INSERT ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetNulls" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [12]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" - AFTER UPDATE ON "parents" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" + AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetNulls" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [13]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" - AFTER INSERT ON "reminders" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" + AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [14]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" - AFTER UPDATE ON "reminders" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" + AFTER INSERT ON "parents" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [15]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" - AFTER DELETE ON "reminders" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" + AFTER INSERT ON "reminders" FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminders'); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [16]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" - AFTER DELETE ON "remindersLists" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" + AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN - DELETE FROM "reminders" - WHERE "remindersListID" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [17]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" - AFTER UPDATE ON "remindersLists" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" + AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN - UPDATE "reminders" - SET "remindersListID" = "new"."id" - WHERE "remindersListID" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [18]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" - AFTER INSERT ON "childWithOnDeleteSetDefaults" + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_users" + AFTER INSERT ON "users" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [19]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" - AFTER UPDATE ON "childWithOnDeleteSetDefaults" + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" + AFTER UPDATE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [20]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" - AFTER DELETE ON "childWithOnDeleteSetDefaults" + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" + AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetDefaults'); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [21]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" - AFTER DELETE ON "parents" + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" + AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetDefaults" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [22]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" + CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetDefaults" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, [23]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" - AFTER INSERT ON "remindersListPrivates" + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" + AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", datetime('subsec') + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -251,25 +256,17 @@ extension BaseCloudKitTests { END """, [25]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" - AFTER DELETE ON "remindersListPrivates" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListPrivates'); - END - """, - [26]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_users" - AFTER INSERT ON "users" + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [27]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_users" AFTER UPDATE ON "users" FOR EACH ROW BEGIN @@ -280,90 +277,93 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, + [27]: """ + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" + BEFORE DELETE ON "parents" + FOR EACH ROW BEGIN + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "childWithOnDeleteRestricts" + WHERE "parentID" = "old"."id"; + END + """, [28]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_users" - AFTER DELETE ON "users" + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" + BEFORE UPDATE ON "parents" FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'users'); + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') + FROM "childWithOnDeleteRestricts" + WHERE "parentID" = "old"."id"; END """, [29]: """ - CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" - AFTER DELETE ON "users" + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" + AFTER DELETE ON "parents" FOR EACH ROW BEGIN - UPDATE "users" - SET "parentUserID" = NULL - WHERE "parentUserID" = "old"."id"; + UPDATE "childWithOnDeleteSetDefaults" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; END """, [30]: """ - CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" - AFTER UPDATE ON "users" + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" + AFTER UPDATE ON "parents" FOR EACH ROW BEGIN - UPDATE "users" - SET "parentUserID" = "new"."id" - WHERE "parentUserID" = "old"."id"; + UPDATE "childWithOnDeleteSetDefaults" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; END """, [31]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" - AFTER INSERT ON "remindersLists" + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" + AFTER DELETE ON "parents" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + UPDATE "childWithOnDeleteSetNulls" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; END """, [32]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" - AFTER UPDATE ON "remindersLists" + CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" + AFTER UPDATE ON "parents" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + UPDATE "childWithOnDeleteSetNulls" + SET "parentID" = NULL + WHERE "parentID" = "old"."id"; END """, [33]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); + DELETE FROM "reminders" + WHERE "remindersListID" = "old"."id"; END """, [34]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" - AFTER INSERT ON "parents" + CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" + AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + UPDATE "reminders" + SET "remindersListID" = "new"."id" + WHERE "remindersListID" = "old"."id"; END """, [35]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" - AFTER UPDATE ON "parents" + CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" + AFTER DELETE ON "users" FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + UPDATE "users" + SET "parentUserID" = NULL + WHERE "parentUserID" = "old"."id"; END """, [36]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" - AFTER DELETE ON "parents" + CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" + AFTER UPDATE ON "users" FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'parents'); + UPDATE "users" + SET "parentUserID" = "new"."id" + WHERE "parentUserID" = "old"."id"; END """ ] @@ -384,7 +384,7 @@ extension BaseCloudKitTests { privateSyncEngine.assertFetchChangesScopes([.all]) sharedSyncEngine.assertFetchChangesScopes([.all]) let triggersAfterReSetUp = try await database.write { db in - try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) + try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } expectNoDifference(triggersAfterReSetUp, triggersAfterSetUp) } From 2cfe926506be959849c0fd9d801a6493f9abc329 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 09:05:20 -0700 Subject: [PATCH 165/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 222 +++++++++--------- 1 file changed, 117 insertions(+), 105 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 7d2c48e4..84dc7e19 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -344,7 +344,7 @@ extension PrimaryKeyedTable { db: Database ) throws { let parentForeignKey = - foreignKeysByTableName[tableName]?.count == 1 + foreignKeysByTableName[tableName]?.count == 1 ? foreignKeysByTableName[tableName]?.first : nil @@ -400,7 +400,7 @@ extension SyncEngine: CKSyncEngineDelegate { case .sentRecordZoneChanges(let event): handleSentRecordZoneChanges(event, syncEngine: syncEngine) case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, - .didFetchChanges, .willSendChanges, .didSendChanges: + .didFetchChanges, .willSendChanges, .didSendChanges: break @unknown default: break @@ -429,57 +429,57 @@ extension SyncEngine: CKSyncEngineDelegate { changes += keyValue.value } -#if DEBUG - struct State { - var missingTables: [CKRecord.ID] = [] - var missingRecords: [CKRecord.ID] = [] - var sentRecords: [CKRecord.ID] = [] - } - let state = LockIsolated(State()) - defer { - let state = state.withValue(\.self) - let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - logger.debug( + #if DEBUG + struct State { + var missingTables: [CKRecord.ID] = [] + var missingRecords: [CKRecord.ID] = [] + var sentRecords: [CKRecord.ID] = [] + } + let state = LockIsolated(State()) + defer { + let state = state.withValue(\.self) + let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + logger.debug( """ [\(syncEngine.scope.label)] nextRecordZoneChangeBatch: \(context.reason) \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") """ - ) - } -#endif + ) + } + #endif let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in -#if DEBUG - var missingTable: CKRecord.ID? - var missingRecord: CKRecord.ID? - var sentRecord: CKRecord.ID? - defer { - state.withValue { [missingTable, missingRecord, sentRecord] in - if let missingTable { $0.missingTables.append(missingTable) } - if let missingRecord { $0.missingRecords.append(missingRecord) } - if let sentRecord { $0.sentRecords.append(sentRecord) } + #if DEBUG + var missingTable: CKRecord.ID? + var missingRecord: CKRecord.ID? + var sentRecord: CKRecord.ID? + defer { + state.withValue { [missingTable, missingRecord, sentRecord] in + if let missingTable { $0.missingTables.append(missingTable) } + if let missingRecord { $0.missingRecords.append(missingRecord) } + if let sentRecord { $0.sentRecords.append(sentRecord) } + } } - } -#endif + #endif guard let recordName = SyncMetadata.RecordName(recordID: recordID), @@ -497,12 +497,12 @@ extension SyncEngine: CKSyncEngineDelegate { } func open>(_: T.Type) async -> CKRecord? { let row = - withErrorReporting { - try database.read { db in - try T.find(recordName.id).fetchOne(db) + withErrorReporting { + try database.read { db in + try T.find(recordName.id).fetchOne(db) + } } - } - ?? nil + ?? nil guard let row else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) @@ -511,11 +511,11 @@ extension SyncEngine: CKSyncEngineDelegate { } let record = - metadata.lastKnownServerRecord - ?? CKRecord( - recordType: metadata.recordType, - recordID: recordID - ) + metadata.lastKnownServerRecord + ?? CKRecord( + recordType: metadata.recordType, + recordID: recordID + ) record.parent = metadata.parentRecordName.flatMap { parentRecordName in guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) else { return nil } @@ -648,11 +648,13 @@ extension SyncEngine: CKSyncEngineDelegate { for (recordID, recordType) in deletions { guard let recordName = SyncMetadata.RecordName(recordID: recordID) else { - reportIssue(""" - Received 'recordName' in invalid format: \(recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """) + reportIssue( + """ + Received 'recordName' in invalid format: \(recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) continue } if let table = tablesByName[recordType] { @@ -702,11 +704,13 @@ extension SyncEngine: CKSyncEngineDelegate { let failedRecord = failedRecordSave.record guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) else { - reportIssue(""" + reportIssue( + """ Attempted to delete record with invalid 'recordName': \(failedRecord.recordID.recordName) - + 'recordName' should be formatted as "uuid:tableName". - """) + """ + ) continue } @@ -762,11 +766,13 @@ extension SyncEngine: CKSyncEngineDelegate { else { return } guard let recordName = SyncMetadata.RecordName(recordID: rootRecord.recordID) else { - reportIssue(""" - Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """) + reportIssue( + """ + Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) return } @@ -811,11 +817,13 @@ extension SyncEngine: CKSyncEngineDelegate { } guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) else { - reportIssue(""" - Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """) + reportIssue( + """ + Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) return } let userModificationDate = @@ -899,11 +907,13 @@ extension SyncEngine: CKSyncEngineDelegate { $isUpdatingWithServerRecord.withValue(true) { guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) else { - reportIssue(""" + reportIssue( + """ Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) - + 'recordName' should be formatted as "uuid:tableName". - """) + """ + ) return } let metadata = metadataFor(recordName: recordName) @@ -972,11 +982,13 @@ extension DatabaseFunction { } guard let recordName = SyncMetadata.RecordName(rawValue: recordName) else { - reportIssue(""" + reportIssue( + """ Received 'recordName' in invalid format: \(recordName) - + 'recordName' should be formatted as "uuid:tableName". - """) + """ + ) return nil } function(recordName) @@ -1059,33 +1071,33 @@ private func validateSchema( ) throws { try database.read { db in for table in tables { -// // TODO: write tests for this -// let columnsWithUniqueConstraints = -// try SQLQueryExpression( -// """ -// SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) -// WHERE "unique" = 1 AND "origin" <> 'pk' -// """, -// as: String.self -// ) -// .fetchAll(db) -// if !columnsWithUniqueConstraints.isEmpty { -// throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) -// } - -// // TODO: write tests for this -// let nonNullColumnsWithNoDefault = -// try SQLQueryExpression( -// """ -// SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) -// WHERE "notnull" = 1 AND "dflt_value" IS NULL -// """, -// as: String.self -// ) -// .fetchAll(db) -// if !nonNullColumnsWithNoDefault.isEmpty { -// throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) -// } + // // TODO: write tests for this + // let columnsWithUniqueConstraints = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + // WHERE "unique" = 1 AND "origin" <> 'pk' + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !columnsWithUniqueConstraints.isEmpty { + // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) + // } + + // // TODO: write tests for this + // let nonNullColumnsWithNoDefault = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) + // WHERE "notnull" = 1 AND "dflt_value" IS NULL + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !nonNullColumnsWithNoDefault.isEmpty { + // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) + // } } } } From 2b3bf50d9df20ca89f43641907d54d9c8caec22f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 09:06:04 -0700 Subject: [PATCH 166/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 116 ++++++++++-------- 1 file changed, 64 insertions(+), 52 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 8d7733b3..a7e232cc 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -337,7 +337,7 @@ extension PrimaryKeyedTable { db: Database ) throws { let parentForeignKey = - foreignKeysByTableName[tableName]?.count == 1 + foreignKeysByTableName[tableName]?.count == 1 ? foreignKeysByTableName[tableName]?.first : nil @@ -639,11 +639,13 @@ extension SyncEngine: CKSyncEngineDelegate { for (recordID, recordType) in deletions { guard let recordName = SyncMetadata.RecordName(recordID: recordID) else { - reportIssue(""" - Received 'recordName' in invalid format: \(recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """) + reportIssue( + """ + Received 'recordName' in invalid format: \(recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) continue } if let table = tablesByName[recordType] { @@ -693,11 +695,13 @@ extension SyncEngine: CKSyncEngineDelegate { let failedRecord = failedRecordSave.record guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) else { - reportIssue(""" + reportIssue( + """ Attempted to delete record with invalid 'recordName': \(failedRecord.recordID.recordName) - + 'recordName' should be formatted as "uuid:tableName". - """) + """ + ) continue } @@ -753,11 +757,13 @@ extension SyncEngine: CKSyncEngineDelegate { else { return } guard let recordName = SyncMetadata.RecordName(recordID: rootRecord.recordID) else { - reportIssue(""" - Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """) + reportIssue( + """ + Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) return } @@ -802,11 +808,13 @@ extension SyncEngine: CKSyncEngineDelegate { } guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) else { - reportIssue(""" - Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """) + reportIssue( + """ + Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) return } let userModificationDate = @@ -890,11 +898,13 @@ extension SyncEngine: CKSyncEngineDelegate { $isUpdatingWithServerRecord.withValue(true) { guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) else { - reportIssue(""" + reportIssue( + """ Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) - + 'recordName' should be formatted as "uuid:tableName". - """) + """ + ) return } let metadata = metadataFor(recordName: recordName) @@ -963,11 +973,13 @@ extension DatabaseFunction { } guard let recordName = SyncMetadata.RecordName(rawValue: recordName) else { - reportIssue(""" + reportIssue( + """ Received 'recordName' in invalid format: \(recordName) - + 'recordName' should be formatted as "uuid:tableName". - """) + """ + ) return nil } function(recordName) @@ -1050,33 +1062,33 @@ private func validateSchema( ) throws { try database.read { db in for table in tables { -// // TODO: write tests for this -// let columnsWithUniqueConstraints = -// try SQLQueryExpression( -// """ -// SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) -// WHERE "unique" = 1 AND "origin" <> 'pk' -// """, -// as: String.self -// ) -// .fetchAll(db) -// if !columnsWithUniqueConstraints.isEmpty { -// throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) -// } - -// // TODO: write tests for this -// let nonNullColumnsWithNoDefault = -// try SQLQueryExpression( -// """ -// SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) -// WHERE "notnull" = 1 AND "dflt_value" IS NULL -// """, -// as: String.self -// ) -// .fetchAll(db) -// if !nonNullColumnsWithNoDefault.isEmpty { -// throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) -// } + // // TODO: write tests for this + // let columnsWithUniqueConstraints = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + // WHERE "unique" = 1 AND "origin" <> 'pk' + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !columnsWithUniqueConstraints.isEmpty { + // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) + // } + + // // TODO: write tests for this + // let nonNullColumnsWithNoDefault = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) + // WHERE "notnull" = 1 AND "dflt_value" IS NULL + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !nonNullColumnsWithNoDefault.isEmpty { + // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) + // } } } } From 10f97f26bc05b6e92ea9f887db73c2bcd29b20de Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 10:41:29 -0700 Subject: [PATCH 167/581] wip --- Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 55fd1767..49b59ce6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -124,7 +124,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "temp-triggers", - "revision" : "365ce8f75dd119bcb4788481fe0590c4c4aee63b" + "revision" : "df1068b08f642f5eee074b64e817d84ebcc36cf4" } }, { From 2ae14a6c909dd7ee7e8bf89fffc5bfe879d382dc Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 11:28:49 -0700 Subject: [PATCH 168/581] split sharing docs from cloudkit docs --- README.md | 2 +- .../Documentation.docc/Articles/CloudKit.md | 266 +----------- .../Articles/SharingWithCloudKit.md | 406 ++++++++++++++++++ .../Documentation.docc/SharingGRDBCore.md | 30 ++ 4 files changed, 439 insertions(+), 265 deletions(-) create mode 100644 Sources/SharingGRDBCore/Documentation.docc/Articles/SharingWithCloudKit.md diff --git a/README.md b/README.md index c7eae4fd..25e27621 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ try modelContext.save() > [Comparison with SwiftData][comparison-swiftdata-article]. Further, if you want to synchronize the local database to CloudKit so that it is available on -devices, simply configure a `SyncEngine` in the entry point of the app: +all your user's devices, simply configure a `SyncEngine` in the entry point of the app: ```swift @main diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 3dfd3b6a..7e689d24 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -1,7 +1,6 @@ # CloudKit synchronization -Learn how to seamlessly add CloudKit synchronization and record sharing to your SharingGRDB -application. +Learn how to seamlessly add CloudKit synchronization to your SharingGRDB application. ## Overview @@ -23,12 +22,6 @@ to make sure you understand how to best prepare your app for cloud synchronizati - [Record conflicts](#Record-conflicts) - [Backwards compatible migrations](#Backwards-compatible-migrations) - [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) - - [Sharing root records](#Sharing-root-records) - - [Sharing foreign key relationships](#Sharing-foreign-key-relationships) - - [One-to-many relationships](#One-to-many-relationships) - - [Many-to-many relationships](#Many-to-many-relationships) - - [One-to-"at most one" relationships](#One-to-at-most-one-relationships) - - [Controlling what data is shared](#Controlling-what-data-is-shared) - [Assets](#Assets) - [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) - [How SharingGRDB handles distributed schema scenarios](#How-SharingGRDB-handles-distributed-schema-scenarios) @@ -299,262 +292,7 @@ extra complications to an app that go beyond the existing complications of shari across many devices. Please read the documentation carefully and thoroughly to understand how to best situate your app for sharing that does not cause problems down the road. -> Note: To enable sharing of records be sure to add a `CKSharingSupported` key to your Info.plist -with a value of `true`. This is subtly documented in [Apple's documentation for sharing]. - -[Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic - -To share a record with another user one must first create a `CKShare`. SharingGRDB provides -the method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` -for a record. Further, the value returned from this method can be stored in a view and be used -to drive a sheet to display a ``CloudSharingView``, which is a wrapper around UIKit's -`UICloudSharingController`: - -```swift -struct RemindersListView: View { - let remindersList: RemindersList - @State var sharedRecord: SharedRecord? - - var body: some View { - Form { - … - } - .toolbar { - Button("Share") { - Task { - await withErrorReporting { - sharedRecord = try await syncEngine.share(record: remindersList) { share in - share[CKShare.SystemFieldKey.title] = "Join '\(remindersList.title)!'" - } - } - } - } - } - .sheet(item: $sharedRecord) { sharedRecord in - CloudSharingView(sharedRecord: sharedRecord) - } - } -} -``` - -When the "Share" button is tapped, a ``SharedRecord`` will be generated and stored as local state -in the view. That will cause a ``CloudSharingView`` sheet to be presented where the user can -configure how they want to share the record. A record can be _unshared_ by presenting the same -``CloudSharingView`` to the user so that they can tap the "Stop sharing" button in the UI. - -#### Sharing root records - -> Important: It is only possible to share "root" records, that is, records with no -> foreign keys. - -A record can be shared only if it is a "root" record. That means it cannot have any -foreign keys whatsoever. As an example, the following `RemindersList` table is a root record because -it does not have any fields pointing to other tables: - -```swift -@Table -struct RemindersList: Identifiable { - let id: UUID - var title = "" -} -``` - -On the other hand, a `Reminder` table with a foreign key pointing to the `RemindersList` is _not_ -a root record: - -```swift -@Table -struct Reminder: Identifiable { - let id: UUID - var title = "" - var isCompleted = false - var remindersListID: RemindersList.ID -} -``` - -Such records cannot be shared because it is not appropriate to also share the parent record -(i.e. the reminders list). For example, suppose you have a list named "Personal" with a reminder -"Get milk". If you share this reminder with someone, and they decide to reassign the reminder to -their "Life" list, what should happen? Should their list be synchronized to your device? -Or what if they rename your personal list? Should that also rename the list on your device? -Or what if they delete the list? Would you want that to delete your list and all the reminders -in the list? - -For these reasons it is not possible to share non-root records, like reminders. Instead, you can -share root records, like reminders lists. If you do invoke ``SyncEngine/share(record:configure:)`` -with a non-root record, a ``SyncEngine/CantShareRecordWithParent`` error will be thrown. - -> Note: A reminder can still be shared as an association to a shared reminders list, as discussed -> [in the next section](). However, a single -> reminder cannot be shared on its own. - -For a more complex example, consider the following diagrammatic schema for a reminders app: - -![Root record diagram](sync-diagram-root-record.png) - -In this schema, a `RemindersList` can have many `Reminder`s, can have a `CoverImage`, and a -`Reminder` can have multiple `Tag`s, and vice-versa. The only table in this diagram that constitutes -a "root" is `RemindersList`. It is the only one with no foreign key relationships. -None of `Reminder`, `CoverImage`, `Tag` or `ReminderTag` can be directly shared on their own -because they are not root tables. - -#### Sharing foreign key relationships - -> Important: Foreign key relationships are automatically synchronized, but only if the related -> record has a single foreign key. Records with multiple foreign keys cannot be synchronized. - -Relationships between models will automatically be shared when sharing a root record, but with -some limitations. An associated record of a shared record will only be shared if it has exactly -one foreign key pointing to the root shared record, whether directly or indirectly -through other records satisfying this property. - -Below we describe some of the most common types of relationships in SQL databases, as well as -which are possible to synchronize, which cannot be synchronized, and which can be adapted to -play nicely with synchronization. - -##### One-to-many relationships - -One-to-many relationships are the simplest to share with other users. As an example, -consider a `RemindersList` table that can have many `Reminder`s associated with it: - -```swift -@Table -struct RemindersList: Identifiable { - let id: UUID - var title = "" -} - -@Table -struct Reminder: Identifiable { - let id: UUID - var title = "" - var isCompleted = false - var remindersListID: RemindersList.ID -} -``` - -Since `RemindersList` is a [root record](#Sharing-root-records) it can be shared, and since -`Reminder` has only one foreign key pointing to `RemindersList`, it too will be shared. - -Further, suppose there was a `ChildReminder` table that had a single foreign key pointing to a -`Reminder`: - -```swift -@Table -struct ChildReminder: Identifiable { - let id: UUID - var title = "" - var isCompleted = false - var parentReminderID: Reminders.ID -} -``` - -This too will be shared because it has one single foreign key pointing to a table that also has -one single foreign key pointing to the root record being shared. - -As a more complex example, consider the following diagrammatic schema: - -![Synchronizing one-to-many relationships](sync-diagram-one-to-many.png) - -In this schema, a `RemindersList` can have many `Reminder`s and a `CoverImage`, and a `Reminder` -can have many `ChildReminder`s. Sharing a `RemindersList` will share all associated reminders, -cover image, and even child reminderes. The child reminders are synchronized because it has a -single foreign key pointing to a table that also has a single foreign key pointing to the root -record. - -##### Many-to-many relationships - -Many-to-many relationships pose a significant problem to sharing and cannot be supported. If a -table has multiple foreign keys, then it will not be shared even if one of those -foreign keys points to the shared record. - -As an example, suppose we had a many-to-many association of a `Tag` table to `Reminder` via a -`ReminderTag` join table: - -```swift -@Table -struct Tag: Identifiable { - let id: UUID - var title = "" -} -@Table -struct ReminderTag: Identifiable { - let id: UUID - var reminderID: Reminder.ID - var tagID: Tag.ID -} -``` - -In diagrammatic form, this schema looks like the following: - -![Synchronizing many-to-many relationships](sync-diagram-many-to-many.png) - -The `ReminderTag` records will _not_ be shared because it has two foreign key -relationships, represented by the two arrows leaving the `ReminderTag` node. As a consequence, -the `Tag` records will also not be shared. Sharing these records cannot be done in a consistent and -logical manner. - -> Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing -> many-to-many relationships. This is also how the Reminders app works on Apple's platforms. -> Sharing a list of reminders with another use does not share its tags with that user. - -To see why this is an acceptable limitation, suppose you share a "Personal" list with someone, which -holds a "Get milk" reminder, and that reminder has a "weekend" tag associated with it. If the tag -were shared with your friend, then what happens when they delete the tag? Would it be appropriate to -delete that tag from all of your reminders, even the ones that were not shared? For these reasons, -and more, records with multiple foreign keys cannot be shared with a record. - -If you want to support many tags associated with a single reminder, you will have no choice -but to turn it into a one-to-many relationship so that each tag belongs to exactly one reminder: - -```swift -@Table -struct Tag: Identifiable { - let id: UUID - var title = "" - var reminderID: Reminder.ID -} -``` - -In diagrammatic form this schema now looks like the following: - -![Many-to-many refactor into a one-to-many relationship](sync-diagram-many-to-many-refactor.png) - -This kind of relationship will now be synchronized automatically. Sharing a `RemindersList` will -automatically share all of its `Reminder`s, which will subsequently also share all of their -`Tag`s. But, this does put responsibility on your application code to properly aggregate -multiple tags together with the same titles. Luckily this is something that SQL excels at. - -##### One-to-"at most one" relationships - -One-to-"at most one" relationships in SQLite allow you to associate zero or one records with -another record. For an example of this, suppose we wanted to hold onto a cover image for reminders -lists (see for more information on synchronizing assets such as images). It -is perfectly fine to hold onto large binary data in SQLite, such as image data, but typically one -should put this data in a separate table. - -This kind of relationship can be modeled in SQLite as a foreign key pointing from image record -to reminders list record, and with a uniqueness constraint on the key. That enforces that at -most one image is associated with a reminders list. - -In diagrammatic form, it looks like this: - -![One-to-"at most one" relationship with uniqueness](sync-diagram-one-to-at-most-one-unique.png) - -Here the `CoverImage` table has a foreign key pointing to the root table `RemindersList`, but -with a uniqueness constraint to enforce that at most one cover image belongs to a list. - -However, due to what is discussed in , this kind of relationship -cannot be synchronized to CloudKit since uniqueness constraints do not play nicely with -distributed data. But, one can still model this kind of relationship by not enforcing the -uniqueness constraint in SQL and instead enforcing it in your application logic. This means -you will model the relationship as a one-to-many (as described in -) and making sure that in your feature's logic you never -create multiple cover images pointing to the same reminders list. - -#### Controlling what data is shared - - +See for more information. ## Assets diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/SharingWithCloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/SharingWithCloudKit.md new file mode 100644 index 00000000..bcd4daa6 --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/SharingWithCloudKit.md @@ -0,0 +1,406 @@ +# Sharing data with other iCloud users + +Learn how to allow your users to share certain records with other iCloud users for collaboration. + +## Overview + +SharingGRDB provides the tools necessary to share a record with another iCloud user so that +multiple users can collaborate on a single record. Sharing a record with another user brings +extra complications to an app that go beyond the existing complications of sharing a schema +across many devices. Please read the documentation carefully and thoroughly to understand +how to best design your schema for sharing that does not cause problems down the road. + +> Important: To enable sharing of records be sure to add a `CKSharingSupported` key to your +Info.plist with a value of `true`. This is subtly documented in [Apple's documentation for sharing]. + +[Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic + + + +## Creating CKShare records + +To share a record with another user one must first create a `CKShare`. SharingGRDB provides +the method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` +for a record. Further, the value returned from this method can be stored in a view and be used +to drive a sheet to display a ``CloudSharingView``, which is a wrapper around UIKit's +`UICloudSharingController`. + +As an example, a reminders app that wants to allow sharing a reminders list with another user +can do so like this: + +```swift +struct RemindersListView: View { + let remindersList: RemindersList + @State var sharedRecord: SharedRecord? + + var body: some View { + Form { + … + } + .toolbar { + Button("Share") { + Task { + await withErrorReporting { + sharedRecord = try await syncEngine.share(record: remindersList) { share in + share[CKShare.SystemFieldKey.title] = "Join '\(remindersList.title)!'" + } + } + } + } + } + .sheet(item: $sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } + } +} +``` + +When the "Share" button is tapped, a ``SharedRecord`` will be generated and stored as local state +in the view. That will cause a ``CloudSharingView`` sheet to be presented where the user can +configure how they want to share the record. A record can be _unshared_ by presenting the same +``CloudSharingView`` to the user so that they can tap the "Stop sharing" button in the UI. + +## Accepting shared records + +Extra steps must be taken to allow a user to _accept_ a shared record. Once the user taps on the +share link sent to them (whether that is by text, email, etc.), the app will be launched and a +special `userDidAcceptCloudKitShareWith` delegate method will be invoked in the app's scene +delegate. Your app must implement this delegate method and invoke the +``SyncEngine/acceptShare(metadata:)`` method. + +As a simplified example, a `UIWindowSceneDelegate` subclass can implement the delegate method like +so: + +```swift +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + @Dependency(\.defaultSyncEngine) var syncEngine + var window: UIWindow? + func windowScene( + _ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } +} +``` + +The unstructured task is necessary because the delegate method does not work with an async context, +and the `acceptShare` method is async. + +## Diving deeper into sharing + +The above gives a broad overview of how one shares a record with a user, and how a user accepts a +shared record. There is, however, a lot more to know about sharing. There are important restrictions +placed on what kind of records you are allowed to share, and what associations of those records are +shared. + +In a nutshell, only "root" records can be directly shared, i.e. records with no foreign keys. +Further, an association of a root record can only be shared if it has only one foreign key pointing +to the root record. And this last rule applies recursively: a leaf association is shared only if +it has exactly one foreign key pointing to a record that also satisfies this property. + +For more in-depth information, keep reading. + +### Sharing root records + +> Important: It is only possible to share "root" records, i.e. records with no foreign keys. + +A record can be shared only if it is a "root" record. That means it cannot have any +foreign keys whatsoever. As an example, the following `RemindersList` table is a root record because +it does not have any fields pointing to other tables: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} +``` + +On the other hand, a `Reminder` table with a foreign key pointing to the `RemindersList` is _not_ +a root record: + +```swift +@Table +struct Reminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var remindersListID: RemindersList.ID +} +``` + +Such records cannot be shared because it is not appropriate to also share the parent record +(i.e. the reminders list). + +For example, suppose you have a list named "Personal" with a reminder "Get milk". If you share this +reminder with someone, then it becomes difficult to figure out what to do when they make certain +changes to the reminder: + +* If they decide to reassign the reminder to their personal "Life" list, what should +happen? Should their "Life" list suddenly be synchronized to your device? +* Or what if they delete the list? Would you want that to delete your list and all of the reminders +in the list? + +For these reasons, and more, it is not possible to share non-root records, like reminders. Instead, +you can share root records, like reminders lists. If you do invoke +``SyncEngine/share(record:configure:)`` with a non-root record, a +``SyncEngine/CantShareRecordWithParent`` error will be thrown. + +> Note: A reminder can still be shared as an association to a shared reminders list, as discussed +> [in the next section](). However, a single +> reminder cannot be shared on its own. + +For a more complex example, consider the following diagrammatic schema for a reminders app: + +@Image(source: "sync-diagram-root-record.png") { + The green node represents a "root" record, i.e. a record with no foreign key relationships. +} + +In this schema, a `RemindersList` can have many `Reminder`s, can have a `CoverImage`, and a +`Reminder` can have multiple `Tag`s, and vice-versa. The only table in this diagram that constitutes +a "root" is `RemindersList`. It is the only one with no foreign key relationships. +None of `Reminder`, `CoverImage`, `Tag` or `ReminderTag` can be directly shared on their own +because they are not root tables. + +#### Sharing foreign key relationships + +> Important: Foreign key relationships are automatically synchronized, but only if the related +> record has a single foreign key. Records with multiple foreign keys cannot be synchronized. + +Relationships between models will automatically be shared when sharing a root record, but with +some limitations. An associated record of a shared record will only be shared if it has exactly +one foreign key pointing to the root shared record, whether directly or indirectly +through other records satisfying this property. + +Below we describe some of the most common types of relationships in SQL databases, as well as +which are possible to synchronize, which cannot be synchronized, and which can be adapted to +play nicely with synchronization. + +##### One-to-many relationships + +One-to-many relationships are the simplest to share with other users. As an example, +consider a `RemindersList` table that can have many `Reminder`s associated with it: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} + +@Table +struct Reminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var remindersListID: RemindersList.ID +} +``` + +Since `RemindersList` is a [root record](#Sharing-root-records) it can be shared, and since +`Reminder` has only one foreign key pointing to `RemindersList`, it too will be shared. + +Further, suppose there was a `ChildReminder` table that had a single foreign key pointing to a +`Reminder`: + +```swift +@Table +struct ChildReminder: Identifiable { + let id: UUID + var title = "" + var isCompleted = false + var parentReminderID: Reminders.ID +} +``` + +This too will be shared because it has one single foreign key pointing to a table that also has +one single foreign key pointing to the root record being shared. + +As a more complex example, consider the following diagrammatic schema: + +@Image(source: "sync-diagram-one-to-many.png") { + The green node is a shareable root record, and all blue records are relationships that will also + be shared when the root is shared. +} +![Synchronizing one-to-many relationships]() + +In this schema, a `RemindersList` can have many `Reminder`s and a `CoverImage`, and a `Reminder` +can have many `ChildReminder`s. Sharing a `RemindersList` will share all associated reminders, +cover image, and even child reminderes. The child reminders are synchronized because it has a +single foreign key pointing to a table that also has a single foreign key pointing to the root +record. + +##### Many-to-many relationships + +Many-to-many relationships pose a significant problem to sharing and cannot be supported. If a +table has multiple foreign keys, then it will not be shared even if one of those +foreign keys points to the shared record. + +As an example, suppose we had a many-to-many association of a `Tag` table to `Reminder` via a +`ReminderTag` join table: + +```swift +@Table +struct Tag: Identifiable { + let id: UUID + var title = "" +} +@Table +struct ReminderTag: Identifiable { + let id: UUID + var reminderID: Reminder.ID + var tagID: Tag.ID +} +``` + +In diagrammatic form, this schema looks like the following: + +@Image(source: sync-diagram-many-to-many.png) { + The green record is a shareable record, the blue record will be shared when the root is shared, + and the light purple records cannot be shared. +} + +The `ReminderTag` records will _not_ be shared because it has two foreign key +relationships, represented by the two arrows leaving the `ReminderTag` node. As a consequence, +the `Tag` records will also not be shared. Sharing these records cannot be done in a consistent and +logical manner. + +> Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing +> many-to-many relationships. This is also how the Reminders app works on Apple's platforms. +> Sharing a list of reminders with another use does not share its tags with that user. + +To see why this is an acceptable limitation, suppose you share a "Personal" list with someone, which +holds a "Get milk" reminder, and that reminder has a "weekend" tag associated with it. If the tag +were shared with your friend, then what happens when they delete the tag? Would it be appropriate to +delete that tag from all of your reminders, even the ones that were not shared? For this reason, +and more, records with multiple foreign keys cannot be shared with a record. + +If you want to support many tags associated with a single reminder, you will have no choice +but to turn it into a one-to-many relationship so that each tag belongs to exactly one reminder: + +```swift +@Table +struct Tag: Identifiable { + let id: UUID + var title = "" + var reminderID: Reminder.ID +} +``` + +In diagrammatic form this schema now looks like the following: + +@Image(source: sync-diagram-many-to-many-refactor.png) { + The green record is a shareable root record, and the blue records will be shared when the root + is shared. +} + +This kind of relationship will now be synchronized automatically. Sharing a `RemindersList` will +automatically share all of its `Reminder`s, which will subsequently also share all of their +`Tag`s. + +But, this does now mean it's possible to have multiple `Tag` rows in the database that have the +same title and thus represent the same tag. You wil have to put extra care in your queries and +application logic to properly aggregate these tags together, but luckily this is something that SQL +excels at. + +##### One-to-"at most one" relationships + + + + + +## Controlling what data is shared + +It is possible to specify that certain associations that are shareable not be shared. For example, +suppose that you want reminders lists to be orderable by your user, and so add a `position` +column to the table: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var position = 0 + var title = "" +} +``` + +Sharing this record will mean also sharing the position of the list. That means when one user +reorders their local lists, even ones that are private to them, it will reorder the lists for +everyone shared. This is probably not what you want. + +So, private and non-shareable information about this record can be stored in a separate table: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} +@Table +struct RemindersListPrivate: Identifiable { + let id: UUID + var position = 0 + var remindersList: RemindersList.ID +} +``` + +And then when creating the ``SyncEngine`` we can specifically ask it to not share this record +when the reminders list is shared by specifying the `privateTables` argument: + +```swift +@main +struct MyApp: App { + init() { + try! prepareDependencies { + $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + container: CKContainer( + identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" + ), + database: $0.defaultDatabase, + tables: [ + RemindersList.self, + Reminder.self, + ], + privateTables: [ + RemindersListPrivate.self + ] + ) + } + } + + … +} +``` + +This table will still be synchronized across all of a single user's devices, but if that user +shares a list with a friend, it will _not_ share the private table, allowing each user to have +their own personal ordering of lists. diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md index 40363056..8cb3fd33 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md @@ -174,6 +174,36 @@ a model context, via a property wrapper: > Note: For more information on how SharingGRDB compares to SwiftData, see > . +Further, if you want to synchronize the local database to CloudKit so that it is available on +all your user's devices, simply configure a `SyncEngine` in the entry point of the app: + +```swift +@main +struct MyApp: App { + init() { + prepareDependencies { + $0.defaultDatabase = try! appDatabase() + $0.defaultSyncEngine = SyncEngine( + container: CKContainer( + identifier: "iCloud.co.mycompany.MyApp" + ), + database: $0.defaultDatabase, + tables: [ + /* ... */ + ] + ) + } + } + // ... +} +``` + +> [!NOTE] +> For more information on synchronizing the database to CloudKit and sharing records with iCloud +> users, see . + +[CloudKit Synchronization] + This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read the [articles](#Essentials) below to learn how to best utilize this library. From a4685f40ff87c77358a5b160946c45f2dd667a78 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 11:29:41 -0700 Subject: [PATCH 169/581] wip --- .../SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 7e689d24..ca300c10 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -325,3 +325,9 @@ See for more information. ## Migrating from Swift Data to SharingGRDB ## Separating schema migrations from data migrations + +## Topics + +### Go deeper + +- From c0f57bb8766b9f1618b240a6e9b0cfb8dd35f090 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 11:33:06 -0700 Subject: [PATCH 170/581] add can imports --- Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift | 2 ++ .../SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/Logging.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift | 2 ++ .../SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift | 2 ++ 11 files changed, 22 insertions(+) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift index 3c84bdb9..2ea6b5cf 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -22,3 +23,4 @@ extension CKContainer { : sharedCloudDatabase } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index ab4081bb..37287e81 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit import CustomDump import StructuredQueriesCore @@ -183,3 +184,4 @@ extension CKRecordZone.ID: @retroactive CustomDumpReflectable { ) } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 809ab4e3..f898a611 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit import Dependencies import SwiftUI @@ -179,3 +180,4 @@ extension SyncEngine { } } #endif +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift index 966180a4..f641e3fc 100644 --- a/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit import Dependencies import GRDB @@ -16,3 +17,4 @@ extension SyncEngine: TestDependencyKey { try! SyncEngine(container: .default(), database: DatabaseQueue(), tables: []) } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index d2eb6829..12f4127e 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit import os @@ -238,3 +239,4 @@ extension CKDatabase.Scope { } } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift index 52b48eb4..2739d7d5 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit import StructuredQueriesCore @@ -87,3 +88,4 @@ extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore self.data = data } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0ab0db61..84b4b0ca 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit import ConcurrencyExtras import OSLog @@ -1099,3 +1100,4 @@ public struct NonNullColumnMustHaveDefault: Error { """ } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift index 0064c546..dfb22407 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -30,3 +31,4 @@ extension CKSyncEngine: SyncEngineProtocol { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine.State: CKSyncEngineStateProtocol { } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 3e98c860..24c82751 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -41,3 +42,4 @@ package protocol CKSyncEngineStateProtocol: Sendable { func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 9bf41d04..92e4494d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -117,3 +118,4 @@ extension SyncMetadata { self.share = share } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 3ddad8c8..e792c730 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -64,3 +65,4 @@ extension PrimaryKeyedTableDefinition { SQLQueryExpression(" \(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") } } +#endif From 7c7997128bcc2036d4d9d5197e17c52643b19232 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 11:33:23 -0700 Subject: [PATCH 171/581] wip --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b870e04a..0c354fe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - cloudkit pull_request: branches: - '*' From ceeb91b540a452b64d3e8311b704a794c4d38b79 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 11:36:10 -0700 Subject: [PATCH 172/581] wip --- Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 2 +- .../Articles/{SharingWithCloudKit.md => CloudKitSharing.md} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename Sources/SharingGRDBCore/Documentation.docc/Articles/{SharingWithCloudKit.md => CloudKitSharing.md} (100%) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index ca300c10..c5fb486e 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -292,7 +292,7 @@ extra complications to an app that go beyond the existing complications of shari across many devices. Please read the documentation carefully and thoroughly to understand how to best situate your app for sharing that does not cause problems down the road. -See for more information. +See for more information. ## Assets diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/SharingWithCloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/SharingWithCloudKit.md rename to Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md From 24be0f13ba4b1f6becc81b4fc15c2a95f075c843 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 12:55:46 -0700 Subject: [PATCH 173/581] add test to show that table with two FKs does not get parentRecordName --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 6 +- .../CloudKit/SyncMetadata.swift | 3 + .../CloudKitTests/CloudKitTests.swift | 25 +++- .../CloudKitTests/MetadataTests.swift | 34 ++++- .../CloudKitTests/SharingTests.swift | 2 - .../CloudKitTests/TriggerTests.swift | 120 ++++++++++++++---- .../Internal/BaseCloudKitTests.swift | 10 +- Tests/SharingGRDBTests/Internal/Schema.swift | 33 ++++- 8 files changed, 196 insertions(+), 37 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index cb8c291f..c45e24ab 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -999,8 +999,10 @@ extension String { @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) extension URL { - fileprivate static func metadatabase(containerIdentifier: String?) -> Self { - applicationSupportDirectory.appending( + package static func metadatabase(containerIdentifier: String?) -> Self { + @Dependency(\.context) var context + let base: URL = context == .live ? .applicationDirectory : .temporaryDirectory + return base.appending( component: "\(containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" ) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index e792c730..bc7f4c82 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -13,7 +13,10 @@ public struct SyncMetadata: Hashable, Sendable { // @Column(as: CKShare?.ShareDataRepresentation.self) public var share: CKShare? public var userModificationDate: Date? +} +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata { public struct RecordName: RawRepresentable, Sendable, Hashable, QueryBindable { public var recordType: String public var id: UUID diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index eead57da..c6e5cc7e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -51,6 +51,25 @@ extension BaseCloudKitTests { """ ), [3]: RecordType( + tableName: "tags", + schema: """ + CREATE TABLE "tags" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ), + [4]: RecordType( + tableName: "reminderTags", + schema: """ + CREATE TABLE "reminderTags" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + ) STRICT + """ + ), + [5]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( @@ -58,7 +77,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [4]: RecordType( + [6]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( @@ -67,7 +86,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [5]: RecordType( + [7]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -76,7 +95,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [6]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index cc3d4ab0..aca2337c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -37,8 +37,6 @@ extension BaseCloudKitTests { try Reminder.find(UUID(1)) .update { $0.remindersListID = UUID(2) } .execute(db) - } - try database.write { db in let reminderMetadata = try #require( try SyncMetadata .find(Reminder.recordName(for: UUID(1))) @@ -50,5 +48,37 @@ extension BaseCloudKitTests { .saveRecord(Reminder.recordID(for: UUID(1))), ]) } + + @Test func noParentRecordForRecordsWithMultipleForeignKeys() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) + Tag(id: UUID(1), title: "weekend") + ReminderTag(id: UUID(1), reminderID: UUID(1), tagID: UUID(1)) + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(1))), + .saveRecord(Tag.recordID(for: UUID(1))), + .saveRecord(ReminderTag.recordID(for: UUID(1))), + ]) + + try database.write { db in + let tagMetadata = try #require( + try SyncMetadata + .find(Tag.recordName(for: UUID(1))) + .fetchOne(db) + ) + #expect(tagMetadata.parentRecordName == nil) + let reminderTagMetadata = try #require( + try SyncMetadata + .find(Tag.recordName(for: UUID(1))) + .fetchOne(db) + ) + #expect(reminderTagMetadata.parentRecordName == nil) + } + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 16ce568d..c784fd42 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -45,8 +45,6 @@ extension BaseCloudKitTests { } } - // TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list - @Table fileprivate struct NonSyncedTable { let id: UUID } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 0bbe6e25..fe435488 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -115,6 +115,82 @@ extension BaseCloudKitTests { END """, [11]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" + AFTER INSERT ON "tags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [12]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" + AFTER UPDATE ON "tags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [13]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags" + AFTER DELETE ON "tags" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'tags'); + END + """, + [14]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" + AFTER INSERT ON "reminderTags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [15]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" + AFTER UPDATE ON "reminderTags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [16]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags" + AFTER DELETE ON "reminderTags" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminderTags'); + END + """, + [17]: """ + CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_tags_onDeleteCascade" + AFTER DELETE ON "tags" + FOR EACH ROW BEGIN + DELETE FROM "reminderTags" + WHERE "tagID" = "old"."id"; + END + """, + [18]: """ + CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_reminders_onDeleteCascade" + AFTER DELETE ON "reminders" + FOR EACH ROW BEGIN + DELETE FROM "reminderTags" + WHERE "reminderID" = "old"."id"; + END + """, + [19]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_users" AFTER INSERT ON "users" FOR EACH ROW BEGIN @@ -125,7 +201,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [12]: """ + [20]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_users" AFTER UPDATE ON "users" FOR EACH ROW BEGIN @@ -136,7 +212,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [13]: """ + [21]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_users" AFTER DELETE ON "users" FOR EACH ROW BEGIN @@ -144,7 +220,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'users'); END """, - [14]: """ + [22]: """ CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" AFTER DELETE ON "users" FOR EACH ROW BEGIN @@ -153,7 +229,7 @@ extension BaseCloudKitTests { WHERE "parentUserID" = "old"."id"; END """, - [15]: """ + [23]: """ CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" AFTER UPDATE ON "users" FOR EACH ROW BEGIN @@ -162,7 +238,7 @@ extension BaseCloudKitTests { WHERE "parentUserID" = "old"."id"; END """, - [16]: """ + [24]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN @@ -173,7 +249,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [17]: """ + [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -184,7 +260,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [18]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -192,7 +268,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'parents'); END """, - [19]: """ + [27]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" AFTER INSERT ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -203,7 +279,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [20]: """ + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" AFTER UPDATE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -214,7 +290,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [21]: """ + [29]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteRestricts" AFTER DELETE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -222,7 +298,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteRestricts'); END """, - [22]: """ + [30]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" BEFORE DELETE ON "parents" FOR EACH ROW BEGIN @@ -231,7 +307,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [23]: """ + [31]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" BEFORE UPDATE ON "parents" FOR EACH ROW BEGIN @@ -240,7 +316,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [24]: """ + [32]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -251,7 +327,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [25]: """ + [33]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -262,7 +338,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [26]: """ + [34]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" AFTER DELETE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -270,7 +346,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetNulls'); END """, - [27]: """ + [35]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -279,7 +355,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [28]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -288,7 +364,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [29]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -299,7 +375,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [30]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -310,7 +386,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [31]: """ + [39]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" AFTER DELETE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -318,7 +394,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetDefaults'); END """, - [32]: """ + [40]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -327,7 +403,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [33]: """ + [41]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 4476236d..fa0221eb 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -27,7 +27,9 @@ class BaseCloudKitTests: @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) init() async throws { - let database = try SharingGRDBTests.database() + let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" + + let database = try SharingGRDBTests.database(containerIdentifier: testContainerIdentifier) let privateSyncEngine = MockSyncEngine(scope: .private, state: MockSyncEngineState()) let sharedSyncEngine = MockSyncEngine(scope: .shared, state: MockSyncEngineState()) self.database = database @@ -37,12 +39,12 @@ class BaseCloudKitTests: @unchecked Sendable { privateSyncEngine: privateSyncEngine, sharedSyncEngine: sharedSyncEngine, database: database, - metadatabaseURL: URL.temporaryDirectory.appending( - path: "metadatabase.\(UUID().uuidString).sqlite" - ), + metadatabaseURL: URL.metadatabase(containerIdentifier: testContainerIdentifier), tables: [ Reminder.self, RemindersList.self, + Tag.self, + ReminderTag.self, User.self, Parent.self, ChildWithOnDeleteRestrict.self, diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index d29cf097..961db3fc 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -10,6 +10,15 @@ import SharingGRDB let id: UUID var title = "" } +@Table struct Tag: Equatable, Identifiable { + let id: UUID + var title = "" +} +@Table struct ReminderTag: Equatable, Identifiable { + let id: UUID + var reminderID: Reminder.ID + var tagID: Tag.ID +} @Table struct User: Equatable, Identifiable { let id: UUID var name = "" @@ -33,10 +42,11 @@ import SharingGRDB } @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -func database() throws -> DatabasePool { +func database(containerIdentifier: String) throws -> DatabasePool { var configuration = Configuration() configuration.foreignKeysEnabled = false - configuration.prepareDatabase { db in + configuration.prepareDatabase { db in + try db.attachMetadatabase(containerIdentifier: containerIdentifier) db.trace { print($0.expandedDescription) } @@ -77,6 +87,25 @@ func database() throws -> DatabasePool { """ ) .execute(db) + try #sql( + """ + CREATE TABLE "tags" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "reminderTags" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) try #sql(""" CREATE TABLE "parents"( "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()) From 4fd7e4115fd4fb5ec9764adfd88bfedca4f54b66 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 15:25:43 -0700 Subject: [PATCH 174/581] expose parentRecordType and parentRecordPrimaryKey for querying --- .../CloudKit/Metadatabase.swift | 6 +- .../CloudKit/SyncMetadata.swift | 11 +++ .../CloudKitTests/MetadataTests.swift | 95 ++++++++++++++++++- 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 2664715a..8ea96d38 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -33,8 +33,6 @@ func defaultMetadatabase( migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create Metadata Tables") { db in - // TODO: Should "recordName" be "collate no case"? - // TODO: Should we have an index of "share" so that we can efficiently find non-nil rows? try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( @@ -48,12 +46,12 @@ func defaultMetadatabase( """ ) .execute(db) - // TODO: Should we have "parentRecordName TEXT REFERENCES metadata(recordName) ON DELETE CASCADE" ? + // TODO: Should we add an index to recordType? // TODO: Do we ever query for "parentRecordName"? should we add an index? try SQLQueryExpression( """ CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_share" - ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("share") + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("share") WHERE "share" IS NOT NULL """ ) .execute(db) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index bc7f4c82..c8bf332d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -55,6 +55,17 @@ extension SyncMetadata { } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata.TableColumns { + public var parentRecordPrimaryKey: some QueryExpression { + SQLQueryExpression("substr(\(parentRecordName), 1, 36)") + } + + public var parentRecordType: some QueryExpression { + SQLQueryExpression("substr(\(parentRecordName), 38)") + } +} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTable { public static func recordName(for id: UUID) -> SyncMetadata.RecordName { diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index aca2337c..c074d2af 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -45,7 +45,7 @@ extension BaseCloudKitTests { #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(2))) } privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(Reminder.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(1))) ]) } @@ -80,5 +80,98 @@ extension BaseCloudKitTests { #expect(reminderTagMetadata.parentRecordName == nil) } } + + @Test func recordType() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(4), title: "Groceries", remindersListID: UUID(1)) + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(3))), + .saveRecord(Reminder.recordID(for: UUID(4))), + ]) + + try database.read { db in + let reminderMetadata = + try SyncMetadata + .where { $0.recordType == Reminder.tableName } + .fetchAll(db) + #expect( + reminderMetadata.map(\.recordName) == [ + Reminder.recordName(for: UUID(2)), + Reminder.recordName(for: UUID(3)), + Reminder.recordName(for: UUID(4)), + ] + ) + } + } + + @Test func parentRecordType() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(4), title: "Groceries", remindersListID: UUID(1)) + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(3))), + .saveRecord(Reminder.recordID(for: UUID(4))), + ]) + + try database.read { db in + let reminderMetadata = + try SyncMetadata + .where { $0.parentRecordType == RemindersList.tableName } + .fetchAll(db) + #expect( + reminderMetadata.map(\.recordName) == [ + Reminder.recordName(for: UUID(2)), + Reminder.recordName(for: UUID(3)), + Reminder.recordName(for: UUID(4)), + ] + ) + } + } + + @Test func parentRecordPrimaryKey() throws { + try database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "Groceries", remindersListID: UUID(1)) + Reminder(id: UUID(4), title: "Groceries", remindersListID: UUID(1)) + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(2))), + .saveRecord(Reminder.recordID(for: UUID(3))), + .saveRecord(Reminder.recordID(for: UUID(4))), + ]) + + try database.read { db in + let reminderMetadata = + try SyncMetadata + .where { $0.parentRecordPrimaryKey == UUID(1) } + .fetchAll(db) + #expect( + reminderMetadata.map(\.recordName) == [ + Reminder.recordName(for: UUID(2)), + Reminder.recordName(for: UUID(3)), + Reminder.recordName(for: UUID(4)), + ] + ) + } + } } } From e9308c37207f0a044106492ab7ec044738cb6d59 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 21 Jun 2025 16:43:59 -0700 Subject: [PATCH 175/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index c8bf332d..ed346559 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -61,6 +61,10 @@ extension SyncMetadata.TableColumns { SQLQueryExpression("substr(\(parentRecordName), 1, 36)") } + public var recordPrimaryKey: some QueryExpression { + SQLQueryExpression("substr(\(recordName), 1, 36)") + } + public var parentRecordType: some QueryExpression { SQLQueryExpression("substr(\(parentRecordName), 38)") } From f258438a5dc9c2be692337c78acef4b6e6fda09d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 22 Jun 2025 12:34:26 -0700 Subject: [PATCH 176/581] wip --- .../Documentation.docc/Articles/CloudKit.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index c5fb486e..fd106076 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -15,9 +15,11 @@ to make sure you understand how to best prepare your app for cloud synchronizati - [Setting up a SyncEngine](#Setting-up-a-SyncEngine) - [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) - [UUID Primary keys](#UUID-Primary-keys) - - [Primary keys on every table](#Primary-keys-on-every-table) + - [Primary keys on every table](#Primary-keys-on-every-table) + - [Foreign key relationships](#Foreign-key-relationships) - [Record conflicts](#Record-conflicts) - [Backwards compatible migrations](#Backwards-compatible-migrations) @@ -165,6 +167,9 @@ CREATE TABLE "reminderTags" ( Note that the `id` column may never be used in your application code, but it is necessary to facilitate synchronizing to CloudKit. + #### Foreign key relationships From 5ff9ebb7b4ca1910246dbe349bd70b4e6f91c4c5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 22 Jun 2025 18:26:34 -0700 Subject: [PATCH 177/581] more docs --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 19 +++++ .../CloudKit/SyncMetadata.swift | 36 ++++++++ .../Documentation.docc/Articles/CloudKit.md | 84 +++++++++++++++++-- .../Articles/PreparingDatabase.md | 1 - 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 84b4b0ca..2ae85d32 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1026,6 +1026,25 @@ struct SyncEngines { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Database { + /// Attaches the metadatabase to an existing database connection. + /// + /// Invoke this method when preparing your database connection in order to allow querying the + /// ``SyncMetadata`` table (see for more info): + /// + /// ```swift + /// func appDatabase() -> any DatabaseWriter { + /// var configuration = Configuration() + /// configuration.prepareDatabase = { db in + /// db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") + /// … + /// } + /// } + /// ``` + /// + /// See for more information on preparing your database. + /// + /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize + /// data. public func attachMetadatabase(containerIdentifier: String) throws { let url = URL.metadatabase(containerIdentifier: containerIdentifier) let path = url.path(percentEncoded: false) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index e792c730..fdfcf034 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -1,17 +1,50 @@ #if canImport(CloudKit) import CloudKit +/// A table that tracks metadata related to synchronized data. +/// +/// Each row of this table represents a synchronized record across all tables synchronized with +/// CloudKit. This means that the sum of the count of rows across all synchronized tables in your +/// application is the number of rows this one single table holds. However, this table is held +/// in a database separate from your app's +/// +/// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") public struct SyncMetadata: Hashable, Sendable { + /// The type of the record synchronized, i.e. the table name. public var recordType: String + + /// The name of the record synchronized. + /// + /// This field encodes both the table name and primary key of the record synchronized in + /// the format "tableName:primaryKey", for example: + /// + /// ```swift + /// "reminders:8c4d1e4e-49b2-4f60-b6df-3c23881b87c6" + /// ``` // @Column(primaryKey: true) public var recordName: RecordName + + /// The name of the record that this record belongs to. + /// + /// This field encodes both the table name and primary key of the parent record in the format + /// "tableName:primaryKey", for example: + /// + /// ```swift + /// "remindersLists:d35e1f81-46e4-45d1-904b-2b7df1661e3e" + /// ``` public var parentRecordName: RecordName? + + /// The last known `CKRecord` received from the server. // @Column(as: CKRecord?.DataRepresentation.self) public var lastKnownServerRecord: CKRecord? + + /// The `CKShare` associated with this record, if it is shared. // @Column(as: CKShare?.ShareDataRepresentation.self) public var share: CKShare? + + /// The date the user last modified the record. public var userModificationDate: Date? public struct RecordName: RawRepresentable, Sendable, Hashable, QueryBindable { @@ -54,6 +87,9 @@ public struct SyncMetadata: Hashable, Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTable { + /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. + /// + /// - Parameter id: The ID of the record. public static func recordName(for id: UUID) -> SyncMetadata.RecordName { SyncMetadata.RecordName(Self.self, id: id) } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index fd106076..e86881db 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -16,10 +16,6 @@ to make sure you understand how to best prepare your app for cloud synchronizati - [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) - [UUID Primary keys](#UUID-Primary-keys) - [Primary keys on every table](#Primary-keys-on-every-table) - - [Foreign key relationships](#Foreign-key-relationships) - [Record conflicts](#Record-conflicts) - [Backwards compatible migrations](#Backwards-compatible-migrations) @@ -100,6 +96,26 @@ to the database will be synchronized to CloudKit. You will still interact with y database the same way you always have. You can use ``FetchAll`` to fetch data to be used in a view or `@Observable` model, and you can use the `defaultDatabase` dependency to write to the database. +There is one additional step you can optionally take if you want to gain access to the underlying +CloudKit metadata that is stored by the library. When constructing the connection to your database +you can use the `prepareDatabase` method on `Configuration` to attach the metadatabase: + +```swift +func appDatabase() -> any DatabaseWriter { + var configuration = Configuration() + configuration.prepareDatabase = { db in + db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") + … + } +} +``` + +This will allow you to query the ``SyncMetadata`` table, which gives you access to the `CKRecord` +stored for each of your records, as well as the `CKShare` for any shared records. + +See the ``GRDB/Database/attachMetadatabase(containerIdentifier:)`` for more information, as well +as below . + ## Designing your schema with synchronization in mind Distributing your app's schema across many devices is a big decision to make for your app, and @@ -306,7 +322,65 @@ See for more information. ## Accessing CloudKit metadata - +While the library tries to make CloudKit synchronization as seamless and hidden as possible, +there are times you will need to access the underlying CloudKit types for your tables and records. +The ``SyncMetadata``table is the central place where this data is stored, and it is publicly +exposed for you to query it in whichever way you want. + +> Important: In order to query the `SyncMetadata` table from your database connection you will need +to attach the metadatabase to your database connection. This can be done with the +``GRDB/Database/attachMetadatabase(containerIdentifier:)`` method defined on `Database`. + +For example, if you want to retrieve the `CKRecord` that is associated with a particular row in +one of your tables, say a reminder, then you can use ``SyncMetadata/lastKnownServerRecord`` to +retreive the `CKRecord` and then invoke a CloudKit database function to retreive all of the details: + +```swift +let metadata = try database.read { db in + try SyncMetadata + .find(RemindersList.recordName(for: remindersListID)) + .fetchOne(db) +} +guard let metadata +else { return } + +let ckRecord = try await container.privateCloudDatabase + .record(for: metadata.lastKnownServerRecord.recordID) +``` + +> Important: In the above snippet we are explicitly using `privateCloudDatabase`, but that is +> only appropriate for unshared records. If your record is shared, which can be determined from +> [SyncMetadata.share](), then you must use `sharedCloudDatabase` to +> fetch the newest record. + +You are free to invoke any CloudKit functions you want with the `CKRecord` retreived from +``SyncMetadata``. Any changes made directly with CloudKit will be automatically synced to your +SQLite database by the ``SyncEngine``. + +It is also possible to fetch the `CKShare` associated with a record if it has been shared: + +```swift +let metadata = try database.read { db in + try SyncMetadata + .find(RemindersList.recordName(for: remindersListID)) + .fetchOne(db) +} +guard + let metadata, + let share = metadata.share +else { return } + +let ckRecord = try await container.sharedCloudDatabase + .record(for: share.recordID) +``` + +> Important: In the above snippet we are using the `sharedCloudDatabase` and this is always +appropriate to use when fetching the details of a `CKShare` as they are always stored in the +shared database. + + + +It is possible to ## How SharingGRDB handles distributed schema scenarios diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md index 4612a5a2..05a2405e 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md @@ -88,7 +88,6 @@ when running your app in the simulator/device and using `Swift.print` in preview > sensitive data that you may not want to leak. In this case we feel it is OK because everything > is surrounded in `#if DEBUG`, but it is something to be careful of in your own apps. - > Tip: `@Dependency(\.context)` comes from the [Swift Dependencies][swift-dependencies-gh] library, > which SharingGRDB uses to share its database connection across fetch keys. It allows you to > inspect the context your app is running in: live, preview or test. From 4f2512b1bee378bad03c80ec12f385692ebd73f2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 22 Jun 2025 20:04:55 -0700 Subject: [PATCH 178/581] some improved tests --- .../CloudKit/RecordTypeTable.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 8 +- .../CloudKitTests/CloudKitTests.swift | 73 +++------- .../CloudKitTests/RecordTypeTests.swift | 128 ++++++++++++++++++ .../CloudKitTests/SyncEngineTests.swift | 48 +++---- 5 files changed, 172 insertions(+), 87 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift index 3925f3bd..9c591a9c 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift @@ -1,5 +1,5 @@ // @Table("\(String.sqliteDataCloudKitSchemaName)_recordTypes") -package struct RecordType { +package struct RecordType: Hashable { // @Column(primaryKey: true) package let tableName: String package let schema: String diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 2ae85d32..a2622bbf 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -206,7 +206,7 @@ public final class SyncEngine: Sendable { /* TODO: When we detect a change in schema should save records? - TODO: Should we save records for everything in a table that is not in metadata? + TODO: Should we save records for everything in a table that is not in metadata? */ if !recordTypesToFetch.isEmpty { @@ -276,11 +276,7 @@ public final class SyncEngine: Sendable { } } } - try setUpSyncEngine( - database: database, - metadatabase: metadatabase, - shouldFetchChanges: true - ) + try await setUpSyncEngine() } func didUpdate(recordName: SyncMetadata.RecordName) { diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index e9d51345..96683e6f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -9,67 +9,26 @@ import Testing extension BaseCloudKitTests { @MainActor final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func setUp() throws { - let zones = try database.write { db in - try RecordType.all.fetchAll(db) - } - assertInlineSnapshot(of: zones, as: .customDump) { - #""" - [ - [0]: RecordType( - tableName: "remindersLists", - schema: """ - CREATE TABLE "remindersLists" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' - ) STRICT - """ - ), - [1]: RecordType( - tableName: "users", - schema: """ - CREATE TABLE "users" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "name" TEXT NOT NULL DEFAULT '', - "parentUserID" TEXT, - - FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE - ) STRICT - """ - ), - [2]: RecordType( - tableName: "reminders", - schema: """ - CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "assignedUserID" TEXT, - "title" TEXT NOT NULL DEFAULT '', - "parentReminderID" TEXT, - "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', - - FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, - FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, - FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE - ) STRICT - """ - ) - ] - """# - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDown() async throws { - _ = try await database.write { db in - try SyncMetadata.count().fetchOne(db) ?? 0 + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + + try await database.write { db in + let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 + #expect(metadataCount == 1) } try await syncEngine.tearDownSyncEngine() -// await #expect(throws: DatabaseError.self) { -// try await self.database.write { db in -// try Metadata.count().fetchOne(db) ?? 0 -// } -// } + try await self.database.write { db in + let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 + #expect(metadataCount == 0) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift new file mode 100644 index 00000000..813ce182 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -0,0 +1,128 @@ +import CloudKit +import ConcurrencyExtras +import CustomDump +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class RecordTypeTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func setUp() async throws { + let recordTypes = try await database.write { db in + try RecordType.all.fetchAll(db) + } + assertInlineSnapshot(of: recordTypes, as: .customDump) { + #""" + [ + [0]: RecordType( + tableName: "remindersLists", + schema: """ + CREATE TABLE "remindersLists" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ), + [1]: RecordType( + tableName: "users", + schema: """ + CREATE TABLE "users" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "name" TEXT NOT NULL DEFAULT '', + "parentUserID" TEXT, + + FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE + ) STRICT + """ + ), + [2]: RecordType( + tableName: "reminders", + schema: """ + CREATE TABLE "reminders" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "assignedUserID" TEXT, + "title" TEXT NOT NULL DEFAULT '', + "parentReminderID" TEXT, + "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + + FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """ + ) + ] + """# + } + } + + @Test func tearDown() async throws { + try await syncEngine.tearDownSyncEngine() + try await database.write { db in + try #expect(RecordType.all.fetchAll(db) == []) + } + } + + @Test func resetUp() async throws { + let recordTypes = try await database.write { db in + try RecordType.all.fetchAll(db) + } + try await syncEngine.tearDownSyncEngine() + try await syncEngine.setUpSyncEngine() + privateSyncEngine.assertFetchChangesScopes([.all]) + sharedSyncEngine.assertFetchChangesScopes([.all]) + let recordTypesAfterReSetup = try await database.write { db in + try RecordType.all.fetchAll(db) + } + expectNoDifference(recordTypes, recordTypesAfterReSetup) + } + + @Test func migration() async throws { + let recordTypes = try await database.write { db in + try RecordType.all.fetchAll(db) + } + try await syncEngine.tearDownSyncEngine() + try await database.write { db in + try #sql( + """ + ALTER TABLE "reminders" ADD COLUMN "newFeature" INTEGER NOT NULL + """ + ) + .execute(db) + } + try await syncEngine.setUpSyncEngine() + privateSyncEngine.assertFetchChangesScopes([.all]) + sharedSyncEngine.assertFetchChangesScopes([.all]) + + let recordTypesAfterMigration = try await database.write { db in + try RecordType.all.fetchAll(db) + } + #expect(recordTypesAfterMigration.count == 3) + #expect(recordTypes[0...1] == recordTypesAfterMigration[0...1]) + + assertInlineSnapshot(of: recordTypesAfterMigration[2], as: .customDump) { + #""" + RecordType( + tableName: "reminders", + schema: """ + CREATE TABLE "reminders" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "assignedUserID" TEXT, + "title" TEXT NOT NULL DEFAULT '', + "parentReminderID" TEXT, + "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', "newFeature" INTEGER NOT NULL, + + FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """ + ) + """# + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index 96946f16..10f35cdf 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -7,29 +7,31 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { -// final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { -// #if os(macOS) -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func foreignKeysDisabled() throws { -// let result = #expect( -// processExitsWith: .failure, -// observing: [\.standardErrorContent] -// ) { -// _ = try SyncEngine( -// privateSyncEngine: MockSyncEngine(scope: .private, state: MockSyncEngineState()), -// sharedSyncEngine: MockSyncEngine(scope: .shared, state: MockSyncEngineState()), -// database: databaseWithForeignKeys(), -// metadatabaseURL: URL.temporaryDirectory, -// tables: [] -// ) -// } -// #expect( -// String(decoding: try #require(result).standardOutputContent, as: UTF8.self) -// == "Foreign key support must be disabled to synchronize with CloudKit." -// ) -// } -// #endif -// } + final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { + #if os(macOS) && compiler(>=6.2) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeysDisabled() throws { + let result = #expect( + processExitsWith: .failure, + observing: [\.standardErrorContent] + ) { + _ = try SyncEngine( + privateSyncEngine: MockSyncEngine(scope: .private, state: MockSyncEngineState()), + sharedSyncEngine: MockSyncEngine(scope: .shared, state: MockSyncEngineState()), + database: databaseWithForeignKeys(), + metadatabaseURL: URL.temporaryDirectory, + tables: [] + ) + } + #expect( + String(decoding: try #require(result).standardOutputContent, as: UTF8.self) + == "Foreign key support must be disabled to synchronize with CloudKit." + ) + } + #endif + +// @Test func + } } private func databaseWithForeignKeys() throws -> any DatabaseWriter { From ed1cd60d667ffe259716dabd214b45ac3ca9431d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 22 Jun 2025 21:15:26 -0700 Subject: [PATCH 179/581] Some work on improving testing --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 17 +++++++++- .../CloudKit/SyncEngineProtocol+Live.swift | 9 ++++++ .../CloudKit/SyncEngineProtocol.swift | 5 +++ .../CloudKitTests/SyncEngineTests.swift | 8 +++-- .../Internal/CloudKitTestHelpers.swift | 32 +++++++++++++++++++ 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 84dc7e19..c32dc2f0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -410,6 +410,21 @@ extension SyncEngine: CKSyncEngineDelegate { public func nextRecordZoneChangeBatch( _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + let syncEngine = syncEngines.withValue { + syncEngine === $0.private ? $0.private : $0.shared + } + guard let syncEngine + else { + reportIssue("TODO") + return nil + } + return await _nextRecordZoneChangeBatch(context, syncEngine: syncEngine) + } + + package func _nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: any SyncEngineProtocol ) async -> CKSyncEngine.RecordZoneChangeBatch? { let allChanges = syncEngine.state.pendingRecordZoneChanges.filter( context.options.scope.contains @@ -467,7 +482,7 @@ extension SyncEngine: CKSyncEngineDelegate { } #endif - let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in + let batch = await syncEngines.withValue(\.private)?.recordZoneChangeBatch(pendingChanges: changes) { recordID in #if DEBUG var missingTable: CKRecord.ID? var missingRecord: CKRecord.ID? diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift index 0064c546..85136a1d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift @@ -26,7 +26,16 @@ extension CKSyncEngine: SyncEngineProtocol { ) ) } + + package func recordZoneChangeBatch( + pendingChanges: [PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> RecordZoneChangeBatch? { + await CKSyncEngine + .RecordZoneChangeBatch(pendingChanges: pendingChanges, recordProvider: recordProvider) + } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine.State: CKSyncEngineStateProtocol { } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 3e98c860..7b2f3260 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -8,6 +8,10 @@ package protocol SyncEngineProtocol: AnyObject, Sendable { var scope: CKDatabase.Scope { get } func acceptShare(metadata: ShareMetadata) async throws func cancelOperations() async + func recordZoneChangeBatch( + pendingChanges: [CKSyncEngine.PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> CKSyncEngine.RecordZoneChangeBatch? } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -36,6 +40,7 @@ package struct ShareMetadata: Hashable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package protocol CKSyncEngineStateProtocol: Sendable { + var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index 96946f16..13c402bf 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -7,7 +7,7 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { -// final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { + final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { // #if os(macOS) // @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) // @Test func foreignKeysDisabled() throws { @@ -29,7 +29,11 @@ extension BaseCloudKitTests { // ) // } // #endif -// } + + @Test func nextRecordZoneChangeBatch() async throws { + + } + } } private func databaseWithForeignKeys() throws -> any DatabaseWriter { diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 518401a7..891ce8e7 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -35,6 +35,34 @@ final class MockSyncEngine: SyncEngineProtocol { _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } } + func recordZoneChangeBatch( + pendingChanges: [CKSyncEngine.PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + let savedRecordIDs: [CKRecord.ID] = state.pendingRecordZoneChanges.compactMap { + guard case .saveRecord(let recordID) = $0 + else { return nil } + return recordID + } + let recordsToSave = await withoutActuallyEscaping(recordProvider) { escapingRecordProvider in + await withTaskGroup(of: CKRecord?.self, returning: [CKRecord].self) { group in + for recordID in savedRecordIDs { + group.addTask { + await escapingRecordProvider(recordID) + } + } + return await group.compactMap { $0 }.reduce(into: []) { $0 += [$1] } + } + } + let recordIDsToDelete: [CKRecord.ID] = state.pendingRecordZoneChanges.compactMap { + guard case .deleteRecord(let recordID) = $0 + else { return nil } + return recordID + } + + return CKSyncEngine.RecordZoneChangeBatch(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) + } + func assertFetchChangesScopes( _ scopes: Set, fileID: StaticString = #fileID, @@ -140,6 +168,10 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol { } } + var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { + _pendingRecordZoneChanges.withValue { Array($0) } + } + func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { self._pendingRecordZoneChanges.withValue { $0.formUnion(pendingRecordZoneChanges) From e72efd5a3d945410145758e67793c2b50f391345 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 07:38:15 -0700 Subject: [PATCH 180/581] some good test coverage on nextRecordZoneChangeBatch --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 3 +- .../CloudKit/SyncMetadata.swift | 16 ++ .../CloudKitTests/SyncEngineTests.swift | 154 +++++++++++++++--- .../Internal/CloudKitTestHelpers.swift | 25 ++- 4 files changed, 164 insertions(+), 34 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c32dc2f0..07b884aa 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -439,6 +439,7 @@ extension SyncEngine: CKSyncEngineDelegate { @unknown default: false } } + // TODO: why did we do this again? can we test it? allChangesByIsDeleted[true]?.reverse() let changes = allChangesByIsDeleted.reduce(into: []) { changes, keyValue in changes += keyValue.value @@ -505,7 +506,6 @@ extension SyncEngine: CKSyncEngineDelegate { } guard let table = tablesByName[metadata.recordType] else { - reportIssue("") syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) missingTable = recordID return nil @@ -534,7 +534,6 @@ extension SyncEngine: CKSyncEngineDelegate { record.parent = metadata.parentRecordName.flatMap { parentRecordName in guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) else { return nil } - return CKRecord.Reference( recordID: CKRecord.ID( recordName: parentRecordName.rawValue, diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 3ddad8c8..fdbc1495 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -13,6 +13,22 @@ public struct SyncMetadata: Hashable, Sendable { public var share: CKShare? public var userModificationDate: Date? + package init( + recordType: String, + recordName: RecordName, + parentRecordName: RecordName? = nil, + lastKnownServerRecord: CKRecord? = nil, + share: CKShare? = nil, + userModificationDate: Date? = nil + ) { + self.recordType = recordType + self.recordName = recordName + self.parentRecordName = parentRecordName + self.lastKnownServerRecord = lastKnownServerRecord + self.share = share + self.userModificationDate = userModificationDate + } + public struct RecordName: RawRepresentable, Sendable, Hashable, QueryBindable { public var recordType: String public var id: UUID diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index 13c402bf..a4909757 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -8,30 +8,132 @@ import Testing extension BaseCloudKitTests { final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { -// #if os(macOS) -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func foreignKeysDisabled() throws { -// let result = #expect( -// processExitsWith: .failure, -// observing: [\.standardErrorContent] -// ) { -// _ = try SyncEngine( -// privateSyncEngine: MockSyncEngine(scope: .private, state: MockSyncEngineState()), -// sharedSyncEngine: MockSyncEngine(scope: .shared, state: MockSyncEngineState()), -// database: databaseWithForeignKeys(), -// metadatabaseURL: URL.temporaryDirectory, -// tables: [] -// ) -// } -// #expect( -// String(decoding: try #require(result).standardOutputContent, as: UTF8.self) -// == "Foreign key support must be disabled to synchronize with CloudKit." -// ) -// } -// #endif - - @Test func nextRecordZoneChangeBatch() async throws { - + // #if os(macOS) + // @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + // @Test func foreignKeysDisabled() throws { + // let result = #expect( + // processExitsWith: .failure, + // observing: [\.standardErrorContent] + // ) { + // _ = try SyncEngine( + // privateSyncEngine: MockSyncEngine(scope: .private, state: MockSyncEngineState()), + // sharedSyncEngine: MockSyncEngine(scope: .shared, state: MockSyncEngineState()), + // database: databaseWithForeignKeys(), + // metadatabaseURL: URL.temporaryDirectory, + // tables: [] + // ) + // } + // #expect( + // String(decoding: try #require(result).standardOutputContent, as: UTF8.self) + // == "Foreign key support must be disabled to synchronize with CloudKit." + // ) + // } + // #endif + + @Test func nextRecordZoneChangeBatch_NoMetadataForRecord() async throws { + privateSyncEngine.state + .add(pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: UUID(1)))]) + #expect(privateSyncEngine.state.pendingRecordZoneChanges == [ + .saveRecord(Reminder.recordID(for: UUID(1))) + ]) + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([Reminder.recordID(for: UUID(1))]) + ) + ).promoted, + syncEngine: privateSyncEngine + ) + #expect(batch?.recordIDsToDelete == []) + #expect(batch?.recordsToSave == []) + + #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) + } + + @Test func nextRecordZoneChangeBatch_NonExistentTable() async throws { + try await database.write { db in + try SyncMetadata.insert { + SyncMetadata( + recordType: UnrecognizedTable.tableName, + recordName: SyncMetadata.RecordName(UnrecognizedTable.self, id: UUID(1)) + ) + } + .execute(db) + } + privateSyncEngine.state + .add(pendingRecordZoneChanges: [.saveRecord(UnrecognizedTable.recordID(for: UUID(1)))]) + #expect(!privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([UnrecognizedTable.recordID(for: UUID(1))]) + ) + ).promoted, + syncEngine: privateSyncEngine + ) + #expect(batch?.recordIDsToDelete == []) + #expect(batch?.recordsToSave == []) + + #expect(privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + } + + @Test func nextRecordZoneChangeBatch_DeletedRow() async throws { + try await database.write { db in + try SyncMetadata.insert { + SyncMetadata( + recordType: RemindersList.tableName, + recordName: SyncMetadata.RecordName(RemindersList.self, id: UUID(1)) + ) + } + .execute(db) + } + privateSyncEngine.state + .add(pendingRecordZoneChanges: [.saveRecord(RemindersList.recordID(for: UUID(1)))]) + #expect(!privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) + ) + ).promoted, + syncEngine: privateSyncEngine + ) + #expect(batch?.recordIDsToDelete == []) + #expect(batch?.recordsToSave == []) + + #expect(privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + } + + @Test func nextRecordZoneChangeBatch_SaveRecord() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + #expect(privateSyncEngine.state.pendingRecordZoneChanges == [ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) + ) + ).promoted, + syncEngine: privateSyncEngine + ) + #expect(batch?.recordIDsToDelete == []) + #expect(batch?.recordsToSave.count == 1) + + let savedRecord = try #require(batch?.recordsToSave.first) + #expect(savedRecord.encryptedValues["title"] == "Personal") + #expect(savedRecord.recordType == RemindersList.tableName) + #expect(savedRecord.recordID == RemindersList.recordID(for: UUID(1))) + + #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) } } } @@ -39,3 +141,7 @@ extension BaseCloudKitTests { private func databaseWithForeignKeys() throws -> any DatabaseWriter { try DatabaseQueue() } + +@Table struct UnrecognizedTable { + let id: UUID +} diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 891ce8e7..c7d0ad28 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -44,16 +44,17 @@ final class MockSyncEngine: SyncEngineProtocol { else { return nil } return recordID } - let recordsToSave = await withoutActuallyEscaping(recordProvider) { escapingRecordProvider in - await withTaskGroup(of: CKRecord?.self, returning: [CKRecord].self) { group in - for recordID in savedRecordIDs { - group.addTask { - await escapingRecordProvider(recordID) - } - } - return await group.compactMap { $0 }.reduce(into: []) { $0 += [$1] } + var recordsToSave: [CKRecord] = [] + defer { + for savedRecord in recordsToSave { + state.remove(pendingRecordZoneChanges: [.saveRecord(savedRecord.recordID)]) } } + for recordID in savedRecordIDs { + guard let record = await recordProvider(recordID) + else { continue } + recordsToSave.append(record) + } let recordIDsToDelete: [CKRecord.ID] = state.pendingRecordZoneChanges.compactMap { guard case .deleteRecord(let recordID) = $0 else { return nil } @@ -225,3 +226,11 @@ extension CKSyncEngine.FetchChangesOptions.Scope: @retroactive Hashable { } } } + +struct SendChangesContext: Sendable { + var reason = CKSyncEngine.SyncReason.scheduled + var options = CKSyncEngine.SendChangesOptions(scope: .all) + var promoted: CKSyncEngine.SendChangesContext { + unsafeBitCast(self, to: CKSyncEngine.SendChangesContext.self) + } +} From 150a6693fb1606a737e5a93b1bcb50c959916d43 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 09:45:54 -0700 Subject: [PATCH 181/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 8 +- .../CloudKit/SyncEngineProtocol.swift | 17 ++ .../NextRecordZoneChangeBatchTests.swift | 152 ++++++++++++++++++ .../CloudKitTests/SyncEngineTests.swift | 110 ------------- .../Internal/CloudKitTestHelpers.swift | 8 - 5 files changed, 175 insertions(+), 120 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 07b884aa..421bfd8b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -419,11 +419,14 @@ extension SyncEngine: CKSyncEngineDelegate { reportIssue("TODO") return nil } - return await _nextRecordZoneChangeBatch(context, syncEngine: syncEngine) + return await _nextRecordZoneChangeBatch( + SendChangesContext(context: context), + syncEngine: syncEngine + ) } package func _nextRecordZoneChangeBatch( - _ context: CKSyncEngine.SendChangesContext, + _ context: SendChangesContext, syncEngine: any SyncEngineProtocol ) async -> CKSyncEngine.RecordZoneChangeBatch? { let allChanges = syncEngine.state.pendingRecordZoneChanges.filter( @@ -534,6 +537,7 @@ extension SyncEngine: CKSyncEngineDelegate { record.parent = metadata.parentRecordName.flatMap { parentRecordName in guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) else { return nil } + //CKRecord.Reference.init(record: <#T##CKRecord#>, action: <#T##CKRecord.ReferenceAction#>) return CKRecord.Reference( recordID: CKRecord.ID( recordName: parentRecordName.rawValue, diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 7b2f3260..8d414b8b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -46,3 +46,20 @@ package protocol CKSyncEngineStateProtocol: Sendable { func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package struct SendChangesContext: Sendable { + package var reason: CKSyncEngine.SyncReason + package var options: CKSyncEngine.SendChangesOptions + package init( + reason: CKSyncEngine.SyncReason = .scheduled, + options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all) + ) { + self.reason = reason + self.options = options + } + init(context: CKSyncEngine.SendChangesContext) { + reason = context.reason + options = context.options + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift new file mode 100644 index 00000000..3f4cf3d3 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -0,0 +1,152 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { + @Test func nextRecordZoneChangeBatch_NoMetadataForRecord() async throws { + privateSyncEngine.state + .add(pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: UUID(1)))]) + #expect(privateSyncEngine.state.pendingRecordZoneChanges == [ + .saveRecord(Reminder.recordID(for: UUID(1))) + ]) + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([Reminder.recordID(for: UUID(1))]) + ) + ), + syncEngine: privateSyncEngine + ) + #expect(batch?.recordIDsToDelete == []) + #expect(batch?.recordsToSave == []) + + #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) + } + + @Test func nextRecordZoneChangeBatch_NonExistentTable() async throws { + try await database.write { db in + try SyncMetadata.insert { + SyncMetadata( + recordType: UnrecognizedTable.tableName, + recordName: SyncMetadata.RecordName(UnrecognizedTable.self, id: UUID(1)) + ) + } + .execute(db) + } + privateSyncEngine.state + .add(pendingRecordZoneChanges: [.saveRecord(UnrecognizedTable.recordID(for: UUID(1)))]) + #expect(!privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([UnrecognizedTable.recordID(for: UUID(1))]) + ) + ), + syncEngine: privateSyncEngine + ) + #expect(batch?.recordIDsToDelete == []) + #expect(batch?.recordsToSave == []) + + #expect(privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + } + + @Test func nextRecordZoneChangeBatch_DeletedRow() async throws { + try await database.write { db in + try SyncMetadata.insert { + SyncMetadata( + recordType: RemindersList.tableName, + recordName: SyncMetadata.RecordName(RemindersList.self, id: UUID(1)) + ) + } + .execute(db) + } + privateSyncEngine.state + .add(pendingRecordZoneChanges: [.saveRecord(RemindersList.recordID(for: UUID(1)))]) + #expect(!privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) + ) + ), + syncEngine: privateSyncEngine + ) + #expect(batch?.recordIDsToDelete == []) + #expect(batch?.recordsToSave == []) + + #expect(privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + } + + @Test func nextRecordZoneChangeBatch_SaveRecord() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + #expect(privateSyncEngine.state.pendingRecordZoneChanges == [ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) + ) + ), + syncEngine: privateSyncEngine + ) + #expect(batch?.recordIDsToDelete == []) + #expect(batch?.recordsToSave.count == 1) + + let savedRecord = try #require(batch?.recordsToSave.first) + #expect(savedRecord.encryptedValues["title"] == "Personal") + #expect(savedRecord.recordType == RemindersList.tableName) + #expect(savedRecord.recordID == RemindersList.recordID(for: UUID(1))) + + #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) + } + + @Test func nextRecordZoneChangeBatch_SaveRecordWithParent() async throws { + try await database.write { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + } + } + privateSyncEngine.state.assertPendingRecordZoneChanges([ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ]) + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([Reminder.recordID(for: UUID(1))]) + ) + ), + syncEngine: privateSyncEngine + ) + #expect(batch?.recordIDsToDelete == []) + #expect(batch?.recordsToSave.count == 1) + + let savedRecord = try #require(batch?.recordsToSave.first) + #expect(savedRecord.encryptedValues["title"] == "Get milk") + #expect(savedRecord.recordType == RemindersList.tableName) + #expect(savedRecord.recordID == RemindersList.recordID(for: UUID(1))) + #expect(savedRecord.parent == CKRecord.Reference.ini) + + #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) + } + } +} + +@Table struct UnrecognizedTable { + let id: UUID +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index a4909757..5cf00c41 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -29,119 +29,9 @@ extension BaseCloudKitTests { // ) // } // #endif - - @Test func nextRecordZoneChangeBatch_NoMetadataForRecord() async throws { - privateSyncEngine.state - .add(pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: UUID(1)))]) - #expect(privateSyncEngine.state.pendingRecordZoneChanges == [ - .saveRecord(Reminder.recordID(for: UUID(1))) - ]) - - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([Reminder.recordID(for: UUID(1))]) - ) - ).promoted, - syncEngine: privateSyncEngine - ) - #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave == []) - - #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) - } - - @Test func nextRecordZoneChangeBatch_NonExistentTable() async throws { - try await database.write { db in - try SyncMetadata.insert { - SyncMetadata( - recordType: UnrecognizedTable.tableName, - recordName: SyncMetadata.RecordName(UnrecognizedTable.self, id: UUID(1)) - ) - } - .execute(db) - } - privateSyncEngine.state - .add(pendingRecordZoneChanges: [.saveRecord(UnrecognizedTable.recordID(for: UUID(1)))]) - #expect(!privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) - - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([UnrecognizedTable.recordID(for: UUID(1))]) - ) - ).promoted, - syncEngine: privateSyncEngine - ) - #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave == []) - - #expect(privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) - } - - @Test func nextRecordZoneChangeBatch_DeletedRow() async throws { - try await database.write { db in - try SyncMetadata.insert { - SyncMetadata( - recordType: RemindersList.tableName, - recordName: SyncMetadata.RecordName(RemindersList.self, id: UUID(1)) - ) - } - .execute(db) - } - privateSyncEngine.state - .add(pendingRecordZoneChanges: [.saveRecord(RemindersList.recordID(for: UUID(1)))]) - #expect(!privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) - - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) - ) - ).promoted, - syncEngine: privateSyncEngine - ) - #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave == []) - - #expect(privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) - } - - @Test func nextRecordZoneChangeBatch_SaveRecord() async throws { - try await database.write { db in - try db.seed { - RemindersList(id: UUID(1), title: "Personal") - } - } - #expect(privateSyncEngine.state.pendingRecordZoneChanges == [ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) - - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) - ) - ).promoted, - syncEngine: privateSyncEngine - ) - #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave.count == 1) - - let savedRecord = try #require(batch?.recordsToSave.first) - #expect(savedRecord.encryptedValues["title"] == "Personal") - #expect(savedRecord.recordType == RemindersList.tableName) - #expect(savedRecord.recordID == RemindersList.recordID(for: UUID(1))) - - #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) - } } } private func databaseWithForeignKeys() throws -> any DatabaseWriter { try DatabaseQueue() } - -@Table struct UnrecognizedTable { - let id: UUID -} diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index c7d0ad28..043be001 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -226,11 +226,3 @@ extension CKSyncEngine.FetchChangesOptions.Scope: @retroactive Hashable { } } } - -struct SendChangesContext: Sendable { - var reason = CKSyncEngine.SyncReason.scheduled - var options = CKSyncEngine.SendChangesOptions(scope: .all) - var promoted: CKSyncEngine.SendChangesContext { - unsafeBitCast(self, to: CKSyncEngine.SendChangesContext.self) - } -} From 26a8fee6f7da7a04cf3128a7111f585575ed613a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 10:56:00 -0700 Subject: [PATCH 182/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 10 +---- .../NextRecordZoneChangeBatchTests.swift | 44 ++++++++++++------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 421bfd8b..5a8fe31a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -411,15 +411,7 @@ extension SyncEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let syncEngine = syncEngines.withValue { - syncEngine === $0.private ? $0.private : $0.shared - } - guard let syncEngine - else { - reportIssue("TODO") - return nil - } - return await _nextRecordZoneChangeBatch( + await _nextRecordZoneChangeBatch( SendChangesContext(context: context), syncEngine: syncEngine ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 3f4cf3d3..015818e0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -11,9 +11,6 @@ extension BaseCloudKitTests { @Test func nextRecordZoneChangeBatch_NoMetadataForRecord() async throws { privateSyncEngine.state .add(pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: UUID(1)))]) - #expect(privateSyncEngine.state.pendingRecordZoneChanges == [ - .saveRecord(Reminder.recordID(for: UUID(1))) - ]) let batch = await syncEngine._nextRecordZoneChangeBatch( SendChangesContext( @@ -91,9 +88,11 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - #expect(privateSyncEngine.state.pendingRecordZoneChanges == [ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) + #expect( + privateSyncEngine.state.pendingRecordZoneChanges == [ + .saveRecord(RemindersList.recordID(for: UUID(1))) + ] + ) let batch = await syncEngine._nextRecordZoneChangeBatch( SendChangesContext( @@ -118,29 +117,40 @@ extension BaseCloudKitTests { try await database.write { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") + } + } + try await database.write { db in + try db.seed { Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) } } - privateSyncEngine.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) + #expect( + privateSyncEngine.state.pendingRecordZoneChanges == [ + .saveRecord(RemindersList.recordID(for: UUID(1))), + .saveRecord(Reminder.recordID(for: UUID(1))), + ] + ) let batch = await syncEngine._nextRecordZoneChangeBatch( SendChangesContext( options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([Reminder.recordID(for: UUID(1))]) + scope: .recordIDs([ + RemindersList.recordID(for: UUID(1)), + Reminder.recordID(for: UUID(1)), + ]) ) ), syncEngine: privateSyncEngine ) #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave.count == 1) - - let savedRecord = try #require(batch?.recordsToSave.first) - #expect(savedRecord.encryptedValues["title"] == "Get milk") - #expect(savedRecord.recordType == RemindersList.tableName) - #expect(savedRecord.recordID == RemindersList.recordID(for: UUID(1))) - #expect(savedRecord.parent == CKRecord.Reference.ini) + #expect(batch?.recordsToSave.count == 2) + + let remindersListRecord = try #require(batch?.recordsToSave.first) + let reminderRecord = try #require(batch?.recordsToSave.last) + #expect(reminderRecord.encryptedValues["title"] == "Get milk") + #expect(reminderRecord.recordType == Reminder.tableName) + #expect(reminderRecord.recordID == Reminder.recordID(for: UUID(1))) + #expect(reminderRecord.parent?.recordID == remindersListRecord.recordID) #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) } From 07a4905d366ded9a4c2ffc00fc32d143f3c573c5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 13:55:31 -0700 Subject: [PATCH 183/581] add sharing back to reminders --- .../xcshareddata/swiftpm/Package.resolved | 8 +-- Examples/Reminders/Reminders.entitlements | 2 +- Examples/Reminders/RemindersApp.swift | 2 +- Examples/Reminders/RemindersDetail.swift | 71 +++++++++++++------ Examples/Reminders/Schema.swift | 2 +- Package.resolved | 6 +- Package.swift | 3 +- 7 files changed, 59 insertions(+), 35 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 976201f4..79c43a71 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", - "version" : "1.7.0" + "revision" : "9810c8d6c2914de251e072312f01d3bf80071852", + "version" : "1.7.1" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "temp-triggers", - "revision" : "365ce8f75dd119bcb4788481fe0590c4c4aee63b" + "branch" : "main", + "revision" : "a6bc6d56fc967c3956fce8c25bd6f051ac6934af" } }, { diff --git a/Examples/Reminders/Reminders.entitlements b/Examples/Reminders/Reminders.entitlements index 3bbe264c..a416e7fa 100644 --- a/Examples/Reminders/Reminders.entitlements +++ b/Examples/Reminders/Reminders.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.co.pointfree.sharing-grdb.Reminders + iCloud.co.pointfree.SQLiteData.demos.Reminders com.apple.developer.icloud-services diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index a5f57a3b..a798934f 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -13,7 +13,7 @@ struct RemindersApp: App { try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer(identifier: "iCloud.co.pointfree.sharing-grdb.Reminders"), + container: CKContainer(identifier: "iCloud.co.pointfree.SQLiteData.demos.Reminders"), database: $0.defaultDatabase, tables: [ RemindersList.self, diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index a6fee706..c4feb750 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,4 +1,5 @@ import CasePaths +import CloudKit import SharingGRDB import SwiftUI import SwiftUINavigation @@ -13,8 +14,10 @@ class RemindersDetailModel: HashableObject { let detailType: DetailType var isNewReminderSheetPresented = false + var sharedRecord: SharedRecord? @ObservationIgnored @Dependency(\.defaultDatabase) private var database + @ObservationIgnored @Dependency(\.defaultSyncEngine) private var syncEngine init(detailType: DetailType) { self.detailType = detailType @@ -59,7 +62,17 @@ class RemindersDetailModel: HashableObject { $ordering.withLock { $0 = .manual } await updateQuery() } - + + func shareButtonTapped() async { + guard let remindersList = detailType.remindersList + else { return } + sharedRecord = await withErrorReporting { + try await syncEngine.share(record: remindersList) { share in + share[CKShare.SystemFieldKey.title] = remindersList.title + } + } + } + private func updateQuery() async { await withErrorReporting { try await $reminderRows.load(remindersQuery, animation: .default) @@ -189,6 +202,9 @@ struct RemindersDetailView: View { } } } + .sheet(item: $model.sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } .toolbar { ToolbarItem(placement: .principal) { Text(model.detailType.navigationTitle) @@ -215,32 +231,41 @@ struct RemindersDetailView: View { } } ToolbarItem(placement: .primaryAction) { - Menu { - Group { - Menu { - ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in - Button { - Task { await model.orderingButtonTapped(ordering) } - } label: { - Text(ordering.rawValue) - ordering.icon - } - } - } label: { - Text("Sort By") - Text(model.ordering.rawValue) - Image(systemName: "arrow.up.arrow.down") - } + HStack(alignment: .firstTextBaseline) { + if model.detailType.is(\.remindersList) { Button { - Task { await model.showCompletedButtonTapped() } + Task { await model.shareButtonTapped() } } label: { - Text(model.showCompleted ? "Hide Completed" : "Show Completed") - Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") + Image(systemName: "square.and.arrow.up") } } - .tint(model.detailType.color) - } label: { - Image(systemName: "ellipsis.circle") + Menu { + Group { + Menu { + ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in + Button { + Task { await model.orderingButtonTapped(ordering) } + } label: { + Text(ordering.rawValue) + ordering.icon + } + } + } label: { + Text("Sort By") + Text(model.ordering.rawValue) + Image(systemName: "arrow.up.arrow.down") + } + Button { + Task { await model.showCompletedButtonTapped() } + } label: { + Text(model.showCompleted ? "Hide Completed" : "Show Completed") + Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") + } + } + .tint(model.detailType.color) + } label: { + Image(systemName: "ellipsis.circle") + } } } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 0fc7bf01..b53c08f2 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -106,7 +106,7 @@ func appDatabase() throws -> any DatabaseWriter { configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in #if DEBUG - try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.sharing-grdb.Reminders") + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.Reminders") db.trace(options: .profile) { if context == .live { logger.debug("\($0.expandedDescription)") diff --git a/Package.resolved b/Package.resolved index 55fd1767..955bd5b0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "06031ccd8037952d044ea2d40ef142bd6ba1cc2071bf587ce7c557ac1209069a", + "originHash" : "794a047bafc2275bf9dfcec9ec08b24621f054b9b331fc8dbfec924f4d5eb72c", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "temp-triggers", - "revision" : "365ce8f75dd119bcb4788481fe0590c4c4aee63b" + "revision" : "d0ca5d1f4373d00658a3bf01f65946e89000fffb", + "version" : "0.8.0" } }, { diff --git a/Package.swift b/Package.swift index 2c6307d5..27def608 100644 --- a/Package.swift +++ b/Package.swift @@ -33,8 +33,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.0.0"), - // .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.4.0"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "temp-triggers"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "main"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ From 0b9fbc4a7545336245bb1293e0a46bed0b02d599 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 14:21:07 -0700 Subject: [PATCH 184/581] wip --- Examples/Reminders/RemindersListForm.swift | 28 +++++++++++-------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 14 ++++++---- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 7862fcee..465a3483 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -86,17 +86,23 @@ struct RemindersListForm: View { reportIssue("No 'remindersListID'") return } - // try RemindersListAsset.insert { - // RemindersListAsset.Draft( - // coverImage: coverImageData, - // remindersListID: remindersListID - // ) - // } onConflict: { - // $0.remindersListID - // } doUpdate: { - // $0.coverImage = coverImageData - // } - // .execute(db) + let existingAsset = try RemindersListAsset + .where { $0.remindersListID.eq(remindersListID) } + .fetchOne(db) + if let existingAsset { + try RemindersListAsset + .find(existingAsset.id) + .update { $0.coverImage = coverImageData } + .execute(db) + } else { + try RemindersListAsset.insert { + RemindersListAsset.Draft( + coverImage: coverImageData, + remindersListID: remindersListID + ) + } + .execute(db) + } } } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index a2622bbf..b9f3e1f2 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -758,12 +758,14 @@ extension SyncEngine: CKSyncEngineDelegate { return } - try await database.write { db in - try SyncMetadata - .find(recordName) - .update { $0.share = share } - .execute(db) - } + try { + try database.write { db in + try SyncMetadata + .find(recordName) + .update { $0.share = share } + .execute(db) + } + }() } private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { From 18380cc5ea4afbfddc50b8984f49b59366b81e56 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 23 Jun 2025 14:49:03 -0700 Subject: [PATCH 185/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b9f3e1f2..ee053e73 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -280,49 +280,57 @@ public final class SyncEngine: Sendable { } func didUpdate(recordName: SyncMetadata.RecordName) { - let zoneID = zoneID(for: recordName) - let syncEngine = syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared - } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: recordName.rawValue, - zoneID: zoneID + DispatchQueue.main.async { + let zoneID = self.zoneID(for: recordName) + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + syncEngine?.state.add( + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: recordName.rawValue, + zoneID: zoneID + ) ) - ) - ] - ) + ] + ) + } } func didDelete(recordName: SyncMetadata.RecordName) { - let zoneID = zoneID(for: recordName) - let syncEngine = syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared - } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .deleteRecord( - CKRecord.ID( - recordName: recordName.rawValue, - zoneID: zoneID + DispatchQueue.main.async { + let zoneID = zoneID(for: recordName) + let syncEngine = syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + syncEngine?.state.add( + pendingRecordZoneChanges: [ + .deleteRecord( + CKRecord.ID( + recordName: recordName.rawValue, + zoneID: zoneID + ) ) - ) - ] - ) + ] + ) + } } private func zoneID(for recordName: SyncMetadata.RecordName) -> CKRecordZone.ID { - let metadata = + let lastKnownServerRecord = withErrorReporting { try metadatabase.read { db in - try SyncMetadata + struct Parent: AliasName {} + return try SyncMetadata .find(recordName) + .leftJoin(SyncMetadata.as(Parent.self).all) { $1.recordName.is($0.parentRecordName) } + .select { $0.lastKnownServerRecord ?? $1.lastKnownServerRecord } .fetchOne(db) } } ?? nil - return metadata?.lastKnownServerRecord?.recordID.zoneID ?? Self.defaultZone.zoneID + // TODO: Clean up double optional + return lastKnownServerRecord??.recordID.zoneID ?? Self.defaultZone.zoneID } } From 3142745eccac6b5c698cd9a70e2eb86571ccf6c0 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 23 Jun 2025 14:49:18 -0700 Subject: [PATCH 186/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ee053e73..13e3a4aa 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -300,8 +300,8 @@ public final class SyncEngine: Sendable { func didDelete(recordName: SyncMetadata.RecordName) { DispatchQueue.main.async { - let zoneID = zoneID(for: recordName) - let syncEngine = syncEngines.withValue { + let zoneID = self.zoneID(for: recordName) + let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } syncEngine?.state.add( From a23959fef7cb17b5e84f096118e8fc68419515b9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 23 Jun 2025 14:56:05 -0700 Subject: [PATCH 187/581] wip --- Examples/Reminders/RemindersLists.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 32895995..93ffd1cb 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -44,6 +44,9 @@ class RemindersListsModel { allCount: $0.count(filter: !$0.isCompleted), flaggedCount: $0.count(filter: $0.isFlagged && !$0.isCompleted), scheduledCount: $0.count(filter: $0.isScheduled), + sharedCount: SyncMetadata.count { + $0.recordType.eq(Reminder.tableName) && $0.share.isNot(nil) + }, todayCount: $0.count(filter: $0.isToday) ) } @@ -165,6 +168,7 @@ class RemindersListsModel { var allCount = 0 var flaggedCount = 0 var scheduledCount = 0 + var sharedCount = 0 var todayCount = 0 } @@ -234,6 +238,14 @@ struct RemindersListsView: View { ) { model.statTapped(.completed) } + ReminderGridCell( + color: .pink, + count: model.stats.sharedCount, + iconName: "square.and.arrow.up.fill", + title: "Shared" + ) { + model.statTapped(.completed) + } } } .buttonStyle(.plain) From d595de7aae9435d21c9874901dfaa560ee2ae10a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 23 Jun 2025 15:29:08 -0700 Subject: [PATCH 188/581] wip --- Examples/Reminders/RemindersDetail.swift | 8 ++++-- Examples/Reminders/RemindersLists.swift | 28 ++++++++++++------- .../CloudKit/SyncMetadata.swift | 15 ++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index c4feb750..ffccb739 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -80,7 +80,6 @@ class RemindersDetailModel: HashableObject { } private var remindersQuery: some StructuredQueriesCore.Statement { - let query = Reminder .where { if !showCompleted { @@ -104,6 +103,8 @@ class RemindersDetailModel: HashableObject { case .flagged: reminder.isFlagged case .remindersList(let list): reminder.remindersListID.eq(list.id) case .scheduled: reminder.isScheduled + case .shared: + SyncMetadata.where { $0.recordPrimaryKey.eq(reminder.remindersListID) }.exists() case .tags(let tags): tag.id.ifnull(UUID(0)).in(tags.map(\.id)) case .today: reminder.isToday } @@ -118,7 +119,6 @@ class RemindersDetailModel: HashableObject { tags: #sql("\($2.jsonNames)") ) } - return query } enum Ordering: String, CaseIterable { @@ -144,6 +144,7 @@ class RemindersDetailModel: HashableObject { case flagged case remindersList(RemindersList) case scheduled + case shared case tags([Tag]) case today } @@ -316,6 +317,7 @@ extension RemindersDetailModel.DetailType { case .flagged: "flagged" case .remindersList(let list): "list_\(list.id)" case .scheduled: "scheduled" + case .shared: "shared" case .tags: "tags" case .today: "today" } @@ -327,6 +329,7 @@ extension RemindersDetailModel.DetailType { case .flagged: "Flagged" case .remindersList(let list): list.title case .scheduled: "Scheduled" + case .shared: "Shared" case .tags(let tags): switch tags.count { case 0: "Tags" @@ -343,6 +346,7 @@ extension RemindersDetailModel.DetailType { case .flagged: .orange case .remindersList(let list): list.color case .scheduled: .red + case .shared: .pink case .tags: .blue case .today: .blue } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 93ffd1cb..a8b58b06 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -45,11 +45,19 @@ class RemindersListsModel { flaggedCount: $0.count(filter: $0.isFlagged && !$0.isCompleted), scheduledCount: $0.count(filter: $0.isScheduled), sharedCount: SyncMetadata.count { - $0.recordType.eq(Reminder.tableName) && $0.share.isNot(nil) + $0.recordType.eq(Reminder.tableName) + && $0.parentRecordName.map { + $0.in( + SyncMetadata + .where { $0.recordType.eq(RemindersList.tableName) && $0.share.isNot(nil) } + .select(\.recordName) + ) + } + ?? false }, todayCount: $0.count(filter: $0.isToday) - ) - } + ) + } ) var stats = Stats() @@ -138,13 +146,13 @@ class RemindersListsModel { } #if DEBUG - func seedDatabaseButtonTapped() { - withErrorReporting { - try database.write { db in - try db.seedSampleData() + func seedDatabaseButtonTapped() { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } } } - } #endif @CasePathable @@ -244,7 +252,7 @@ struct RemindersListsView: View { iconName: "square.and.arrow.up.fill", title: "Shared" ) { - model.statTapped(.completed) + model.statTapped(.shared) } } } @@ -309,7 +317,7 @@ struct RemindersListsView: View { .listStyle(.insetGrouped) .toolbar { #if DEBUG - ToolbarItem(placement: .automatic) { + ToolbarItem(placement: .automatic) { Menu { Button { model.seedDatabaseButtonTapped() diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index fdfcf034..e73360f0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -85,6 +85,21 @@ public struct SyncMetadata: Hashable, Sendable { } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata.TableColumns { + public var parentRecordPrimaryKey: some QueryExpression { + SQLQueryExpression("substr(\(parentRecordName), 1, 36)") + } + + public var recordPrimaryKey: some QueryExpression { + SQLQueryExpression("substr(\(recordName), 1, 36)") + } + + public var parentRecordType: some QueryExpression { + SQLQueryExpression("substr(\(parentRecordName), 38)") + } +} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTable { /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. From 60658158ba592188fcba3768cde72d71d3020793 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 15:49:29 -0700 Subject: [PATCH 189/581] remove syncing and sharing from cloudkit demo --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 7 -- .../CloudKitDemo/CountersListFeature.swift | 72 +------------------ 2 files changed, 1 insertion(+), 78 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index e18656f2..3abd8468 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -29,13 +29,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { ) -> Bool { try! prepareDependencies { $0.defaultDatabase = try appDatabase() - $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.SharingGRDB.CloudKitDemo" - ), - database: $0.defaultDatabase, - tables: [Counter.self] - ) } return true } diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 59d0557d..b41a3df6 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -6,30 +6,9 @@ import SwiftUINavigation struct CountersListView: View { @FetchAll var counters: [Counter] - @FetchAll var sharedCounters: [CounterWithShare] @Dependency(\.defaultDatabase) var database @State var confirmDeletion: Counter? - init() { - _counters = FetchAll(Counter.nonShared) - _sharedCounters = FetchAll( - Counter.withShare - .select { - CounterWithShare.Columns( - counter: $0, - share: #sql("\($1.share)") - ) - } - ) - } - - @Selection - struct CounterWithShare { - let counter: Counter - @Column(as: CKShare.ShareDataRepresentation.self) - let share: CKShare - } - var body: some View { List { if !counters.isEmpty { @@ -52,36 +31,6 @@ struct CountersListView: View { Text("Counters") } } - if !sharedCounters.isEmpty { - Section { - ForEach(sharedCounters, id: \.counter.id) { counterWithShare in - CounterRow(counter: counterWithShare.counter) - .buttonStyle(.borderless) - .swipeActions { - Button("Delete") { - confirmDeletion = counterWithShare.counter - } - .tint(.red) - } - } - } header: { - Text("Shared counters") - } - .alert(item: $confirmDeletion) { counter in - Text("Delete shared counter?") - } actions: { counter in - Button("Delete", role: .destructive) { - withErrorReporting { - try database.write { db in - try Counter.find(counter.id).delete() - .execute(db) - } - } - } - } message: { counter in - Text("If you delete this counter, other people will no longer have access to it.") - } - } } .navigationTitle("Counters") .toolbar { @@ -89,7 +38,7 @@ struct CountersListView: View { Button("Add") { withErrorReporting { try database.write { db in - try Counter.insert(Counter.Draft()) + try Counter.insert { Counter.Draft() } .execute(db) } } @@ -102,8 +51,6 @@ struct CountersListView: View { struct CounterRow: View { let counter: Counter @Dependency(\.defaultDatabase) var database - @Dependency(\.defaultSyncEngine) var syncEngine - @State var sharedRecord: SharedRecord? var body: some View { HStack { @@ -128,23 +75,6 @@ struct CounterRow: View { } } } - Spacer() - Button { - Task { - await withErrorReporting { - sharedRecord = try await syncEngine.share(record: counter) { share in - share[CKShare.SystemFieldKey.title] = "Join my counter!" - } - } - } - } label: { - Image(systemName: "square.and.arrow.up") - } - } -#if canImport(UIKit) - .sheet(item: $sharedRecord) { sharedRecord in - CloudSharingView(sharedRecord: sharedRecord) } - #endif } } From 99cd22441c4f481f20a1185e07e40ea085a74bbd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 15:50:08 -0700 Subject: [PATCH 190/581] wip --- Examples/CloudKitDemo/Schema.swift | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 79f9f4c9..59e84ba3 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -5,19 +5,6 @@ import SharingGRDB struct Counter: Identifiable { let id: UUID var count = 0 - - static let withShare = Counter - .join(Metadata.all) { - #sql("\($0.id) = \($1.recordName)") - && $1.share.isNot(nil) - } - - static let nonShared = Counter - .where { counter in - !counter.id.in( - #sql("\(Metadata.where { $0.share.isNot(nil) }.select(\.recordName))") - ) - } } func appDatabase() throws -> any DatabaseWriter { @@ -28,7 +15,6 @@ func appDatabase() throws -> any DatabaseWriter { db.trace { print($0.expandedDescription) } - try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SharingGRDB.CloudKitDemo") } let database = try DatabasePool(path: path, configuration: configuration) From 4fc79b24b7ff2753d8656dde2882233f2a9ad57b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:04:04 -0700 Subject: [PATCH 191/581] wip --- .../CloudKitDemo/CloudKitDemo.entitlements | 2 +- Examples/CloudKitDemo/CloudKitDemoApp.swift | 24 +++++++------------ .../CloudKitDemo/CountersListFeature.swift | 15 ++++++++++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemo.entitlements b/Examples/CloudKitDemo/CloudKitDemo.entitlements index 306c5a99..5df5b1b1 100644 --- a/Examples/CloudKitDemo/CloudKitDemo.entitlements +++ b/Examples/CloudKitDemo/CloudKitDemo.entitlements @@ -8,7 +8,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.co.pointfree.SharingGRDB.CloudKitDemo + iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo com.apple.developer.icloud-services diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index 3abd8468..c5dbc3d9 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -7,9 +7,8 @@ import UIKit @main struct CloudKitDemoApp: App { -#if canImport(UIKit) @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate - #endif + var body: some Scene { WindowGroup { NavigationStack { @@ -19,27 +18,22 @@ struct CloudKitDemoApp: App { } } -#if canImport(UIKit) -class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { - @Dependency(\.defaultSyncEngine) var syncEngine +class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil ) -> Bool { try! prepareDependencies { $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + container: CKContainer(identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo"), + database: $0.defaultDatabase, + tables: [ + Counter.self + ] + ) } return true } - - func application( - _ application: UIApplication, - userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata - ) { - Task { - try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) - } - } } -#endif diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index b41a3df6..939cb1a6 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -50,7 +50,9 @@ struct CountersListView: View { struct CounterRow: View { let counter: Counter + @State var sharedRecord: SharedRecord? @Dependency(\.defaultDatabase) var database + @Dependency(\.defaultSyncEngine) var syncEngine var body: some View { HStack { @@ -75,6 +77,19 @@ struct CounterRow: View { } } } + Spacer() + Button { + Task { + sharedRecord = try await syncEngine.share(record: counter) { share in + share[CKShare.SystemFieldKey.title] = "Join my counter!" + } + } + } label: { + Image(systemName: "square.and.arrow.up") + } + } + .sheet(item: $sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) } } } From 8a8f70540baf74d626e253000e63bbe6267cf383 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:07:17 -0700 Subject: [PATCH 192/581] wip --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index c5dbc3d9..184aa27e 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -20,6 +20,8 @@ struct CloudKitDemoApp: App { class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { + @Dependency(\.defaultSyncEngine) var syncEngine + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil @@ -36,4 +38,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { } return true } + + func application( + _ application: UIApplication, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } } From d9188a1276524b4464b785ec057941ef654735b7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:17:20 -0700 Subject: [PATCH 193/581] wip --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 31 +++++++++++++++++---- Examples/CloudKitDemo/Info.plist | 7 +++++ Examples/Reminders/RemindersApp.swift | 1 - 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index 184aa27e..72ad617b 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -7,8 +7,9 @@ import UIKit @main struct CloudKitDemoApp: App { +#if canImport(UIKit) @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate - + #endif var body: some Scene { WindowGroup { NavigationStack { @@ -18,7 +19,7 @@ struct CloudKitDemoApp: App { } } - +#if canImport(UIKit) class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { @Dependency(\.defaultSyncEngine) var syncEngine @@ -29,11 +30,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { try! prepareDependencies { $0.defaultDatabase = try appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer(identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo"), + container: CKContainer( + identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo" + ), database: $0.defaultDatabase, - tables: [ - Counter.self - ] + tables: [Counter.self] ) } return true @@ -41,10 +42,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { func application( _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + func windowScene( + _ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata ) { + @Dependency(\.defaultSyncEngine) var syncEngine Task { try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) } } } +#endif diff --git a/Examples/CloudKitDemo/Info.plist b/Examples/CloudKitDemo/Info.plist index 9ef96ef8..16fd2333 100644 --- a/Examples/CloudKitDemo/Info.plist +++ b/Examples/CloudKitDemo/Info.plist @@ -4,6 +4,13 @@ CKSharingSupported + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIBackgroundModes remote-notification diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index a798934f..07cf7081 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -75,4 +75,3 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } } - From 1a7510e284baa78bd0ffacd2bf54b39ff755b7ba Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:21:05 -0700 Subject: [PATCH 194/581] wip --- .../CloudKitDemo/CountersListFeature.swift | 45 ++++++++++++++++--- Examples/CloudKitDemo/Schema.swift | 1 + 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 939cb1a6..17d67cd5 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -1,19 +1,52 @@ import CloudKit import SharingGRDB -import SharingGRDB import SwiftUI import SwiftUINavigation struct CountersListView: View { - @FetchAll var counters: [Counter] + @FetchAll( + Counter + .join(SyncMetadata.all) { $0.id.eq($1.recordPrimaryKey) } + .where { $1.share.is(nil) } + .select { counter, _ in counter } + ) + var localCounters: [Counter] + @FetchAll( + Counter + .join(SyncMetadata.all) { $0.id.eq($1.recordPrimaryKey) } + .where { $1.share.isNot(nil) } + .select { counter, _ in counter } + ) + var sharedCounters: [Counter] @Dependency(\.defaultDatabase) var database @State var confirmDeletion: Counter? var body: some View { List { - if !counters.isEmpty { + if !localCounters.isEmpty { + Section { + ForEach(localCounters) { counter in + CounterRow(counter: counter) + .buttonStyle(.borderless) + } + .onDelete { indexSet in + withErrorReporting { + try database.write { db in + for index in indexSet { + try Counter.find(localCounters[index].id).delete() + .execute(db) + } + } + } + } + } header: { + Text("Local counters") + } + } + + if !sharedCounters.isEmpty { Section { - ForEach(counters) { counter in + ForEach(sharedCounters) { counter in CounterRow(counter: counter) .buttonStyle(.borderless) } @@ -21,14 +54,14 @@ struct CountersListView: View { withErrorReporting { try database.write { db in for index in indexSet { - try Counter.find(counters[index].id).delete() + try Counter.find(sharedCounters[index].id).delete() .execute(db) } } } } } header: { - Text("Counters") + Text("Shared counters") } } } diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 59e84ba3..83b4977e 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -12,6 +12,7 @@ func appDatabase() throws -> any DatabaseWriter { var configuration = Configuration() configuration.foreignKeysEnabled = false configuration.prepareDatabase { db in + db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") db.trace { print($0.expandedDescription) } From 20a52078b50bc1dfa80d56cd30765de56f664a4a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:21:17 -0700 Subject: [PATCH 195/581] wip --- Examples/CloudKitDemo/Schema.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 83b4977e..09d812bd 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -12,7 +12,7 @@ func appDatabase() throws -> any DatabaseWriter { var configuration = Configuration() configuration.foreignKeysEnabled = false configuration.prepareDatabase { db in - db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") db.trace { print($0.expandedDescription) } From 4c742595c1483dbbc63b17f409ae841fc3276352 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:23:17 -0700 Subject: [PATCH 196/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 13e3a4aa..86056112 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -642,16 +642,16 @@ extension SyncEngine: CKSyncEngineDelegate { } for (recordID, recordType) in deletions { - guard let recordName = SyncMetadata.RecordName(recordID: recordID) - else { - reportIssue(""" + if let table = tablesByName[recordType] { + guard let recordName = SyncMetadata.RecordName(recordID: recordID) + else { + reportIssue(""" Received 'recordName' in invalid format: \(recordID.recordName) 'recordName' should be formatted as "uuid:tableName". """) - continue - } - if let table = tablesByName[recordType] { + continue + } func open>(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in From 03db664541e793d61deaeb04c71975b0497e43d2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:33:56 -0700 Subject: [PATCH 197/581] wip --- .../CloudKitDemo/CountersListFeature.swift | 76 ++++++++++++------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 17d67cd5..3f26d4ee 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -10,17 +10,26 @@ struct CountersListView: View { .where { $1.share.is(nil) } .select { counter, _ in counter } ) - var localCounters: [Counter] + var localCounters @FetchAll( Counter .join(SyncMetadata.all) { $0.id.eq($1.recordPrimaryKey) } .where { $1.share.isNot(nil) } - .select { counter, _ in counter } + .select { + SharedCounter.Columns(counter: $0, share: #sql("\($1.share)")) + } ) - var sharedCounters: [Counter] + var sharedCounters @Dependency(\.defaultDatabase) var database @State var confirmDeletion: Counter? + @Selection + struct SharedCounter { + let counter: Counter + @Column(as: CKShare.ShareDataRepresentation.self) + let share: CKShare + } + var body: some View { List { if !localCounters.isEmpty { @@ -46,15 +55,15 @@ struct CountersListView: View { if !sharedCounters.isEmpty { Section { - ForEach(sharedCounters) { counter in - CounterRow(counter: counter) + ForEach(sharedCounters, id: \.counter.id) { sharedCounter in + CounterRow(counter: sharedCounter.counter, share: sharedCounter.share) .buttonStyle(.borderless) } .onDelete { indexSet in withErrorReporting { try database.write { db in for index in indexSet { - try Counter.find(sharedCounters[index].id).delete() + try Counter.find(sharedCounters[index].counter.id).delete() .execute(db) } } @@ -83,42 +92,51 @@ struct CountersListView: View { struct CounterRow: View { let counter: Counter + var share: CKShare? @State var sharedRecord: SharedRecord? @Dependency(\.defaultDatabase) var database @Dependency(\.defaultSyncEngine) var syncEngine var body: some View { - HStack { - Text("\(counter.count)") - Button("-") { - withErrorReporting { - try database.write { db in - try Counter.find(counter.id).update { - $0.count -= 1 + VStack { + HStack { + Text("\(counter.count)") + Button("-") { + withErrorReporting { + try database.write { db in + try Counter.find(counter.id).update { + $0.count -= 1 + } + .execute(db) } - .execute(db) } } - } - Button("+") { - withErrorReporting { - try database.write { db in - try Counter.find(counter.id).update { - $0.count += 1 + Button("+") { + withErrorReporting { + try database.write { db in + try Counter.find(counter.id).update { + $0.count += 1 + } + .execute(db) } - .execute(db) } } - } - Spacer() - Button { - Task { - sharedRecord = try await syncEngine.share(record: counter) { share in - share[CKShare.SystemFieldKey.title] = "Join my counter!" + Spacer() + Button { + Task { + sharedRecord = try await syncEngine.share(record: counter) { share in + share[CKShare.SystemFieldKey.title] = "Join my counter!" + } } + } label: { + Image(systemName: "square.and.arrow.up") } - } label: { - Image(systemName: "square.and.arrow.up") + } + + if let share { + Text(share.participants + .compactMap { $0.userIdentity.nameComponents?.formatted() } + .joined(separator: ", ")) } } .sheet(item: $sharedRecord) { sharedRecord in From 601ec69ee79df3065bcca6a51be764d6603fdc40 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:36:17 -0700 Subject: [PATCH 198/581] wip --- .../CloudKitDemo/CountersListFeature.swift | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 3f26d4ee..226d9801 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -8,7 +8,9 @@ struct CountersListView: View { Counter .join(SyncMetadata.all) { $0.id.eq($1.recordPrimaryKey) } .where { $1.share.is(nil) } - .select { counter, _ in counter } + .select { + Row.Columns(counter: $0, share: $1.share) + } ) var localCounters @FetchAll( @@ -16,7 +18,7 @@ struct CountersListView: View { .join(SyncMetadata.all) { $0.id.eq($1.recordPrimaryKey) } .where { $1.share.isNot(nil) } .select { - SharedCounter.Columns(counter: $0, share: #sql("\($1.share)")) + Row.Columns(counter: $0, share: $1.share) } ) var sharedCounters @@ -24,25 +26,25 @@ struct CountersListView: View { @State var confirmDeletion: Counter? @Selection - struct SharedCounter { + struct Row { let counter: Counter - @Column(as: CKShare.ShareDataRepresentation.self) - let share: CKShare + @Column(as: CKShare?.ShareDataRepresentation.self) + let share: CKShare? } var body: some View { List { if !localCounters.isEmpty { Section { - ForEach(localCounters) { counter in - CounterRow(counter: counter) + ForEach(localCounters, id: \.counter.id) { row in + CounterRow(row: row) .buttonStyle(.borderless) } .onDelete { indexSet in withErrorReporting { try database.write { db in for index in indexSet { - try Counter.find(localCounters[index].id).delete() + try Counter.find(localCounters[index].counter.id).delete() .execute(db) } } @@ -55,8 +57,8 @@ struct CountersListView: View { if !sharedCounters.isEmpty { Section { - ForEach(sharedCounters, id: \.counter.id) { sharedCounter in - CounterRow(counter: sharedCounter.counter, share: sharedCounter.share) + ForEach(sharedCounters, id: \.counter.id) { row in + CounterRow(row: row) .buttonStyle(.borderless) } .onDelete { indexSet in @@ -91,8 +93,7 @@ struct CountersListView: View { } struct CounterRow: View { - let counter: Counter - var share: CKShare? + let row: CountersListView.Row @State var sharedRecord: SharedRecord? @Dependency(\.defaultDatabase) var database @Dependency(\.defaultSyncEngine) var syncEngine @@ -100,11 +101,11 @@ struct CounterRow: View { var body: some View { VStack { HStack { - Text("\(counter.count)") + Text("\(row.counter.count)") Button("-") { withErrorReporting { try database.write { db in - try Counter.find(counter.id).update { + try Counter.find(row.counter.id).update { $0.count -= 1 } .execute(db) @@ -114,7 +115,7 @@ struct CounterRow: View { Button("+") { withErrorReporting { try database.write { db in - try Counter.find(counter.id).update { + try Counter.find(row.counter.id).update { $0.count += 1 } .execute(db) @@ -124,7 +125,7 @@ struct CounterRow: View { Spacer() Button { Task { - sharedRecord = try await syncEngine.share(record: counter) { share in + sharedRecord = try await syncEngine.share(record: row.counter) { share in share[CKShare.SystemFieldKey.title] = "Join my counter!" } } @@ -133,7 +134,7 @@ struct CounterRow: View { } } - if let share { + if let share = row.share { Text(share.participants .compactMap { $0.userIdentity.nameComponents?.formatted() } .joined(separator: ", ")) From b16e44958ff4762c088228b19f293ad9571d701e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:39:45 -0700 Subject: [PATCH 199/581] wip --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 16 ---- .../CloudKitDemo/CountersListFeature.swift | 85 ++----------------- 2 files changed, 9 insertions(+), 92 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index 72ad617b..b0d1e15d 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -29,13 +29,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { ) -> Bool { try! prepareDependencies { $0.defaultDatabase = try appDatabase() - $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo" - ), - database: $0.defaultDatabase, - tables: [Counter.self] - ) } return true } @@ -56,14 +49,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - func windowScene( - _ windowScene: UIWindowScene, - userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata - ) { - @Dependency(\.defaultSyncEngine) var syncEngine - Task { - try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) - } - } } #endif diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 226d9801..12358f9c 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -4,47 +4,22 @@ import SwiftUI import SwiftUINavigation struct CountersListView: View { - @FetchAll( - Counter - .join(SyncMetadata.all) { $0.id.eq($1.recordPrimaryKey) } - .where { $1.share.is(nil) } - .select { - Row.Columns(counter: $0, share: $1.share) - } - ) - var localCounters - @FetchAll( - Counter - .join(SyncMetadata.all) { $0.id.eq($1.recordPrimaryKey) } - .where { $1.share.isNot(nil) } - .select { - Row.Columns(counter: $0, share: $1.share) - } - ) - var sharedCounters + @FetchAll var counters: [Counter] @Dependency(\.defaultDatabase) var database - @State var confirmDeletion: Counter? - - @Selection - struct Row { - let counter: Counter - @Column(as: CKShare?.ShareDataRepresentation.self) - let share: CKShare? - } var body: some View { List { - if !localCounters.isEmpty { + if !counters.isEmpty { Section { - ForEach(localCounters, id: \.counter.id) { row in - CounterRow(row: row) + ForEach(counters) { counter in + CounterRow(counter: counter) .buttonStyle(.borderless) } .onDelete { indexSet in withErrorReporting { try database.write { db in for index in indexSet { - try Counter.find(localCounters[index].counter.id).delete() + try Counter.find(counters[index].id).delete() .execute(db) } } @@ -54,27 +29,6 @@ struct CountersListView: View { Text("Local counters") } } - - if !sharedCounters.isEmpty { - Section { - ForEach(sharedCounters, id: \.counter.id) { row in - CounterRow(row: row) - .buttonStyle(.borderless) - } - .onDelete { indexSet in - withErrorReporting { - try database.write { db in - for index in indexSet { - try Counter.find(sharedCounters[index].counter.id).delete() - .execute(db) - } - } - } - } - } header: { - Text("Shared counters") - } - } } .navigationTitle("Counters") .toolbar { @@ -93,19 +47,17 @@ struct CountersListView: View { } struct CounterRow: View { - let row: CountersListView.Row - @State var sharedRecord: SharedRecord? + let counter: Counter @Dependency(\.defaultDatabase) var database - @Dependency(\.defaultSyncEngine) var syncEngine var body: some View { VStack { HStack { - Text("\(row.counter.count)") + Text("\(counter.count)") Button("-") { withErrorReporting { try database.write { db in - try Counter.find(row.counter.id).update { + try Counter.find(counter.id).update { $0.count -= 1 } .execute(db) @@ -115,33 +67,14 @@ struct CounterRow: View { Button("+") { withErrorReporting { try database.write { db in - try Counter.find(row.counter.id).update { + try Counter.find(counter.id).update { $0.count += 1 } .execute(db) } } } - Spacer() - Button { - Task { - sharedRecord = try await syncEngine.share(record: row.counter) { share in - share[CKShare.SystemFieldKey.title] = "Join my counter!" - } - } - } label: { - Image(systemName: "square.and.arrow.up") - } } - - if let share = row.share { - Text(share.participants - .compactMap { $0.userIdentity.nameComponents?.formatted() } - .joined(separator: ", ")) - } - } - .sheet(item: $sharedRecord) { sharedRecord in - CloudSharingView(sharedRecord: sharedRecord) } } } From 138f1ce55ca6a676b70e118bda5ba2a515f748b8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 16:39:54 -0700 Subject: [PATCH 200/581] wip --- Examples/CloudKitDemo/Schema.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 09d812bd..59e84ba3 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -12,7 +12,6 @@ func appDatabase() throws -> any DatabaseWriter { var configuration = Configuration() configuration.foreignKeysEnabled = false configuration.prepareDatabase { db in - try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") db.trace { print($0.expandedDescription) } From 976229c969af81a62f48ea01fd5d8e9b55cdf852 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 17:10:40 -0700 Subject: [PATCH 201/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 4 ++-- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 1 - .../NextRecordZoneChangeBatchTests.swift | 14 +++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index ab4081bb..fab2882f 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -152,8 +152,8 @@ extension CKRecord: @retroactive CustomDumpReflectable { public var customDumpMirror: Mirror { return Mirror( self, - children: self.allKeys().sorted().map { - ($0, self[$0] as Any) + children: self.encryptedValues.allKeys().sorted().map { + ($0, self.encryptedValues[$0] as Any) }, displayStyle: .struct ) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5a8fe31a..ef78f53e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -529,7 +529,6 @@ extension SyncEngine: CKSyncEngineDelegate { record.parent = metadata.parentRecordName.flatMap { parentRecordName in guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) else { return nil } - //CKRecord.Reference.init(record: <#T##CKRecord#>, action: <#T##CKRecord.ReferenceAction#>) return CKRecord.Reference( recordID: CKRecord.ID( recordName: parentRecordName.rawValue, diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 015818e0..d9ba74c9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -117,15 +117,11 @@ extension BaseCloudKitTests { try await database.write { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") - } - } - try await database.write { db in - try db.seed { Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) } } #expect( - privateSyncEngine.state.pendingRecordZoneChanges == [ + Set(privateSyncEngine.state.pendingRecordZoneChanges) == [ .saveRecord(RemindersList.recordID(for: UUID(1))), .saveRecord(Reminder.recordID(for: UUID(1))), ] @@ -145,8 +141,12 @@ extension BaseCloudKitTests { #expect(batch?.recordIDsToDelete == []) #expect(batch?.recordsToSave.count == 2) - let remindersListRecord = try #require(batch?.recordsToSave.first) - let reminderRecord = try #require(batch?.recordsToSave.last) + let remindersListRecord = try #require( + batch?.recordsToSave.first(where: { $0.recordType == RemindersList.tableName }) + ) + let reminderRecord = try #require( + batch?.recordsToSave.first(where: { $0.recordType == Reminder.tableName }) + ) #expect(reminderRecord.encryptedValues["title"] == "Get milk") #expect(reminderRecord.recordType == Reminder.tableName) #expect(reminderRecord.recordID == Reminder.recordID(for: UUID(1))) From 029d23d5df2f64080836f019127cb5b58cb934cc Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 23 Jun 2025 17:47:06 -0700 Subject: [PATCH 202/581] Update Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift --- Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 665dd94d..7be0ff81 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -16,6 +16,7 @@ public struct SharedRecord: Hashable, Identifiable, Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { + // TODO: Move errors into single 'SyncEngine.Error' type? public struct UnrecognizedTable: Error {} public struct RecordMustBeRoot: Error {} public struct NoCKRecordFound: Error {} From d5a4989c7921aa1cdb756a2be3124af61ecf70f5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 23 Jun 2025 20:17:08 -0700 Subject: [PATCH 203/581] fixes --- Package.resolved | 6 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 9 +- .../CloudKit/SyncEngineProtocol.swift | 1 + .../CloudKit/SyncMetadata.swift | 8 +- .../CloudKitTests/RecordTypeTests.swift | 76 ++++++++-- .../CloudKitTests/TriggerTests.swift | 134 ++++++++++++++---- 6 files changed, 179 insertions(+), 55 deletions(-) diff --git a/Package.resolved b/Package.resolved index 955bd5b0..e4b94aa4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "794a047bafc2275bf9dfcec9ec08b24621f054b9b331fc8dbfec924f4d5eb72c", + "originHash" : "4258891d6ef61a2877809fa8c7c05dead61bd1f48387c61976e9a691ab6a6176", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "d0ca5d1f4373d00658a3bf01f65946e89000fffb", - "version" : "0.8.0" + "branch" : "main", + "revision" : "a6bc6d56fc967c3956fce8c25bd6f051ac6934af" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index e432ad31..9760510d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -287,7 +287,7 @@ public final class SyncEngine: Sendable { } func didUpdate(recordName: SyncMetadata.RecordName) { - DispatchQueue.main.async { +// DispatchQueue.main.async { let zoneID = self.zoneID(for: recordName) let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -302,11 +302,11 @@ public final class SyncEngine: Sendable { ) ] ) - } +// } } func didDelete(recordName: SyncMetadata.RecordName) { - DispatchQueue.main.async { +// DispatchQueue.main.async { let zoneID = self.zoneID(for: recordName) let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -321,7 +321,7 @@ public final class SyncEngine: Sendable { ) ] ) - } +// } } private func zoneID(for recordName: SyncMetadata.RecordName) -> CKRecordZone.ID { @@ -1166,4 +1166,5 @@ private struct HashablePrimaryKeyedTableType: Hashable { static func == (lhs: Self, rhs: Self) -> Bool { lhs.type == rhs.type } +} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index b0217185..c84624cf 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -63,4 +63,5 @@ package struct SendChangesContext: Sendable { reason = context.reason options = context.options } +} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index cd96fb2e..0fdc8ba2 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -6,7 +6,7 @@ import CloudKit /// Each row of this table represents a synchronized record across all tables synchronized with /// CloudKit. This means that the sum of the count of rows across all synchronized tables in your /// application is the number of rows this one single table holds. However, this table is held -/// in a database separate from your app's +/// in a database separate from your app's database. /// /// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -46,10 +46,7 @@ public struct SyncMetadata: Hashable, Sendable { /// The date the user last modified the record. public var userModificationDate: Date? -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { package init( recordType: String, recordName: RecordName, @@ -65,7 +62,10 @@ extension SyncMetadata { self.share = share self.userModificationDate = userModificationDate } +} +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata { public struct RecordName: RawRepresentable, Sendable, Hashable, QueryBindable { public var recordType: String public var id: UUID diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 813ce182..01481a3d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -21,7 +21,7 @@ extension BaseCloudKitTests { tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '' ) STRICT """ @@ -30,7 +30,7 @@ extension BaseCloudKitTests { tableName: "users", schema: """ CREATE TABLE "users" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "name" TEXT NOT NULL DEFAULT '', "parentUserID" TEXT, @@ -42,17 +42,67 @@ extension BaseCloudKitTests { tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "assignedUserID" TEXT, + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '', - "parentReminderID" TEXT, - "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + "remindersListID" TEXT NOT NULL, - FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, - FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ + ), + [3]: RecordType( + tableName: "tags", + schema: """ + CREATE TABLE "tags" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ), + [4]: RecordType( + tableName: "reminderTags", + schema: """ + CREATE TABLE "reminderTags" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + ) STRICT + """ + ), + [5]: RecordType( + tableName: "parents", + schema: """ + CREATE TABLE "parents"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()) + ) STRICT + """ + ), + [6]: RecordType( + tableName: "childWithOnDeleteRestricts", + schema: """ + CREATE TABLE "childWithOnDeleteRestricts"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT + ) STRICT + """ + ), + [7]: RecordType( + tableName: "childWithOnDeleteSetNulls", + schema: """ + CREATE TABLE "childWithOnDeleteSetNulls"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + ) STRICT + """ + ), + [8]: RecordType( + tableName: "childWithOnDeleteSetDefaults", + schema: """ + CREATE TABLE "childWithOnDeleteSetDefaults"( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', + "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + ) STRICT + """ ) ] """# @@ -100,8 +150,8 @@ extension BaseCloudKitTests { let recordTypesAfterMigration = try await database.write { db in try RecordType.all.fetchAll(db) } - #expect(recordTypesAfterMigration.count == 3) #expect(recordTypes[0...1] == recordTypesAfterMigration[0...1]) + #expect(recordTypes[3...] == recordTypesAfterMigration[3...]) assertInlineSnapshot(of: recordTypesAfterMigration[2], as: .customDump) { #""" @@ -109,14 +159,10 @@ extension BaseCloudKitTests { tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "assignedUserID" TEXT, + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '', - "parentReminderID" TEXT, - "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', "newFeature" INTEGER NOT NULL, + "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, - FOREIGN KEY("assignedUserID") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE, - FOREIGN KEY("parentReminderID") REFERENCES "reminders"("id") ON DELETE RESTRICT ON UPDATE RESTRICT, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 2c4b516f..083d60f5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -70,6 +70,14 @@ extension BaseCloudKitTests { END """, [7]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags" + AFTER DELETE ON "reminderTags" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminderTags'); + END + """, + [8]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN @@ -77,7 +85,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminders'); END """, - [8]: """ + [9]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" AFTER DELETE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -85,7 +93,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListPrivates'); END """, - [9]: """ + [10]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -93,7 +101,15 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); END """, - [10]: """ + [11]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags" + AFTER DELETE ON "tags" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'tags'); + END + """, + [12]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_users" AFTER DELETE ON "users" FOR EACH ROW BEGIN @@ -101,7 +117,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'users'); END """, - [11]: """ + [13]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" AFTER INSERT ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -112,7 +128,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [12]: """ + [14]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -123,7 +139,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [13]: """ + [15]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -134,7 +150,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [14]: """ + [16]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN @@ -145,7 +161,18 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [15]: """ + [17]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" + AFTER INSERT ON "reminderTags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [18]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN @@ -156,7 +183,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [16]: """ + [19]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -167,7 +194,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [17]: """ + [20]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -178,7 +205,18 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [18]: """ + [21]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" + AFTER INSERT ON "tags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [22]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_users" AFTER INSERT ON "users" FOR EACH ROW BEGIN @@ -189,7 +227,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [19]: """ + [23]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" AFTER UPDATE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -200,7 +238,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [20]: """ + [24]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -211,7 +249,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [21]: """ + [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -222,7 +260,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [22]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -233,7 +271,18 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [23]: """ + [27]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" + AFTER UPDATE ON "reminderTags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -244,7 +293,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [24]: """ + [29]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -255,7 +304,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [25]: """ + [30]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -266,7 +315,18 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [26]: """ + [31]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" + AFTER UPDATE ON "tags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName", "userModificationDate") + SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", datetime('subsec') + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [32]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_users" AFTER UPDATE ON "users" FOR EACH ROW BEGIN @@ -277,7 +337,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [27]: """ + [33]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" BEFORE DELETE ON "parents" FOR EACH ROW BEGIN @@ -286,7 +346,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [28]: """ + [34]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" BEFORE UPDATE ON "parents" FOR EACH ROW BEGIN @@ -295,7 +355,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [29]: """ + [35]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -304,7 +364,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [30]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -313,7 +373,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [31]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -322,7 +382,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [32]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -331,7 +391,23 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [33]: """ + [39]: """ + CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_reminders_onDeleteCascade" + AFTER DELETE ON "reminders" + FOR EACH ROW BEGIN + DELETE FROM "reminderTags" + WHERE "reminderID" = "old"."id"; + END + """, + [40]: """ + CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_tags_onDeleteCascade" + AFTER DELETE ON "tags" + FOR EACH ROW BEGIN + DELETE FROM "reminderTags" + WHERE "tagID" = "old"."id"; + END + """, + [41]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -339,7 +415,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [34]: """ + [42]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -348,7 +424,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [35]: """ + [43]: """ CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" AFTER DELETE ON "users" FOR EACH ROW BEGIN @@ -357,7 +433,7 @@ extension BaseCloudKitTests { WHERE "parentUserID" = "old"."id"; END """, - [36]: """ + [44]: """ CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" AFTER UPDATE ON "users" FOR EACH ROW BEGIN From 1b7503a0720da464192b7697e583c01b1a415cee Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 24 Jun 2025 09:56:30 -0700 Subject: [PATCH 204/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 114 ++++++++--------- .../SharingGRDBCore/CloudKit/Triggers.swift | 71 ++++++++--- .../CloudKitTests/CloudKitTests.swift | 7 +- .../NextRecordZoneChangeBatchTests.swift | 1 + .../CloudKitTests/SharingTests.swift | 116 +++++++++++++++++- .../CloudKitTests/SyncEngineTests.swift | 1 + .../CloudKitTests/TriggerTests.swift | 58 +++++---- .../Internal/BaseCloudKitTests.swift | 6 +- .../Internal/CloudKitTestHelpers.swift | 17 +-- 9 files changed, 284 insertions(+), 107 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 9760510d..c428e5c8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -163,6 +163,7 @@ public final class SyncEngine: Sendable { ) .execute(db) } + db.add(function: .datetime) db.add(function: .isUpdatingWithServerRecord) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) @@ -251,6 +252,7 @@ public final class SyncEngine: Sendable { db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .isUpdatingWithServerRecord) + db.remove(function: .datetime) } try await database.write { db in // TODO: Do an `.erase()` + re-migrate @@ -286,58 +288,38 @@ public final class SyncEngine: Sendable { try await setUpSyncEngine() } - func didUpdate(recordName: SyncMetadata.RecordName) { -// DispatchQueue.main.async { - let zoneID = self.zoneID(for: recordName) - let syncEngine = self.syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared - } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: recordName.rawValue, - zoneID: zoneID - ) + func didUpdate(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { + let zoneID = zoneID ?? Self.defaultZone.zoneID + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + syncEngine?.state.add( + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: recordName.rawValue, + zoneID: zoneID ) - ] - ) -// } + ) + ] + ) } - func didDelete(recordName: SyncMetadata.RecordName) { -// DispatchQueue.main.async { - let zoneID = self.zoneID(for: recordName) - let syncEngine = self.syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared - } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .deleteRecord( - CKRecord.ID( - recordName: recordName.rawValue, - zoneID: zoneID - ) + func didDelete(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { + let zoneID = zoneID ?? Self.defaultZone.zoneID + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + syncEngine?.state.add( + pendingRecordZoneChanges: [ + .deleteRecord( + CKRecord.ID( + recordName: recordName.rawValue, + zoneID: zoneID ) - ] - ) -// } - } - - private func zoneID(for recordName: SyncMetadata.RecordName) -> CKRecordZone.ID { - let lastKnownServerRecord = - withErrorReporting { - try metadatabase.read { db in - struct Parent: AliasName {} - return try SyncMetadata - .find(recordName) - .leftJoin(SyncMetadata.as(Parent.self).all) { $1.recordName.is($0.parentRecordName) } - .select { $0.lastKnownServerRecord ?? $1.lastKnownServerRecord } - .fetchOne(db) - } - } ?? nil - // TODO: Clean up double optional - return lastKnownServerRecord??.recordID.zoneID ?? Self.defaultZone.zoneID + ) + ] + ) } } @@ -483,7 +465,7 @@ extension SyncEngine: CKSyncEngineDelegate { } #endif - let batch = await syncEngines.withValue(\.private)?.recordZoneChangeBatch(pendingChanges: changes) { recordID in + let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in #if DEBUG var missingTable: CKRecord.ID? var missingRecord: CKRecord.ID? @@ -968,14 +950,29 @@ extension SyncEngine: CKSyncEngineDelegate { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { recordName in - syncEngine.didUpdate(recordName: recordName) + Self("didUpdate") { recordName, zoneID in + syncEngine.didUpdate( + recordName: recordName, + zoneID: zoneID + ) } } fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { - return Self("didDelete") { recordName in - syncEngine.didDelete(recordName: recordName) + return Self("didDelete") { recordName, zoneID in + syncEngine.didDelete(recordName: recordName, zoneID: zoneID) + } + } + + fileprivate static var datetime: Self { + Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in + @Dependency(\.date.now) var now + return now.formatted( + .iso8601 + .year().month().day() + .dateTimeSeparator(.space) + .time(includingFractionalSeconds: true) + ) } } @@ -988,9 +985,9 @@ extension DatabaseFunction { private convenience init( _ name: String, - function: @escaping @Sendable (SyncMetadata.RecordName) -> Void + function: @escaping @Sendable (SyncMetadata.RecordName, CKRecordZone.ID?) -> Void ) { - self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 1) { arguments in + self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 2) { arguments in guard let recordName = String.fromDatabaseValue(arguments[0]) else { @@ -1007,7 +1004,12 @@ extension DatabaseFunction { ) return nil } - function(recordName) + let zoneID = try Data.fromDatabaseValue(arguments[1]).flatMap { + let coder = try NSKeyedUnarchiver(forReadingFrom: $0) + coder.requiresSecureCoding = true + return CKRecord(coder: coder)?.recordID.zoneID + } + function(recordName, zoneID) return nil } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 1e5a4fc4..33e18e9f 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -1,3 +1,4 @@ +import CloudKit import Foundation @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -43,9 +44,10 @@ extension SyncMetadata { new: TemporaryTrigger.Operation.New, parentForeignKey: ForeignKey?, ) -> some StructuredQueriesCore.Statement { - let parentForeignKey = parentForeignKey.map { - #""new"."\#($0.from)" || ':' || '\#($0.table)'"# - } ?? "NULL" + let parentForeignKey = + parentForeignKey.map { + #""new"."\#($0.from)" || ':' || '\#($0.table)'"# + } ?? "NULL" return insert { ($0.recordType, $0.recordName, $0.parentRecordName, $0.userModificationDate) } select: { @@ -53,7 +55,7 @@ extension SyncMetadata { T.tableName, new.recordName, SQLQueryExpression(#"\#(raw: parentForeignKey) AS "foreignKey""#), - .datetime("subsec") + .datetime() ) } onConflict: { $0.recordName @@ -75,11 +77,13 @@ extension SyncMetadata { ] } + private enum ParentSyncMetadata: AliasName {} + fileprivate static let afterInsertTrigger = createTemporaryTrigger( "after_insert_on_sqlitedata_icloud_metadata", ifNotExists: true, - after: .insert { - Values(.didUpdate($0.recordName)) + after: .insert { new in + Values(.didUpdate(new)) } when: { _ in !isUpdatingWithServerRecord() } @@ -89,7 +93,7 @@ extension SyncMetadata { "after_update_on_sqlitedata_icloud_metadata", ifNotExists: true, after: .update { _, new in - Values(.didUpdate(new.recordName)) + Values(.didUpdate(new)) } when: { _, _ in !isUpdatingWithServerRecord() } @@ -98,24 +102,59 @@ extension SyncMetadata { fileprivate static let afterDeleteTrigger = createTemporaryTrigger( "after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, - after: .delete { - Values(.didDelete($0.recordName)) + after: .delete { old in + Values(.didDelete(old)) } when: { _ in !isUpdatingWithServerRecord() } ) } - extension QueryExpression where Self == SQLQueryExpression<()> { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func didUpdate(_ expression: some QueryExpression) -> Self { - Self("\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(expression))") + fileprivate static func didUpdate( + _ new: StructuredQueriesCore.TableAlias.Operation._New>.TableColumns + ) -> Self { + .didUpdate( + recordName: new.recordName, + lastKnownServerRecord: new.lastKnownServerRecord + ?? SyncMetadata + .where { $0.recordName.is(new.parentRecordName) } + .select(\.lastKnownServerRecord) + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func didDelete( + _ old: StructuredQueriesCore.TableAlias.Operation._Old>.TableColumns + ) + -> Self + { + .didDelete( + recordName: old.recordName, + lastKnownServerRecord: old.lastKnownServerRecord + ?? SyncMetadata + .where { $0.recordName.is(old.parentRecordName) } + .select(\.lastKnownServerRecord) + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private static func didUpdate( + recordName: some QueryExpression, + lastKnownServerRecord: some QueryExpression + ) -> Self { + Self("\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(recordName), \(lastKnownServerRecord))") } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func didDelete(_ expression: some QueryExpression) -> Self { - Self("\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(expression))") + private static func didDelete( + recordName: some QueryExpression, + lastKnownServerRecord: some QueryExpression + ) + -> Self + { + Self("\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord))") } } @@ -124,8 +163,8 @@ private func isUpdatingWithServerRecord() -> SQLQueryExpression { } extension QueryExpression { - fileprivate static func datetime>(_ string: String) -> Self + fileprivate static func datetime>() -> Self where Self == SQLQueryExpression { - Self("datetime(\(quote: string, delimiter: .text))") + Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 6c0f9cda..c68929ad 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -184,9 +184,10 @@ extension BaseCloudKitTests { ) { """ [ - [0]: "sqlitedata_icloud_didupdate", - [1]: "sqlitedata_icloud_isupdatingwithserverrecord", - [2]: "sqlitedata_icloud_diddelete" + [0]: "sqlitedata_icloud_datetime", + [1]: "sqlitedata_icloud_didupdate", + [2]: "sqlitedata_icloud_isupdatingwithserverrecord", + [3]: "sqlitedata_icloud_diddelete" ] """ } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index d9ba74c9..86fb1097 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -7,6 +7,7 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { + @MainActor final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { @Test func nextRecordZoneChangeBatch_NoMetadataForRecord() async throws { privateSyncEngine.state diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index b0baaf9e..d8c1225c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -7,6 +7,7 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { + @MainActor final class SharingTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareNonRootRecord() async throws { @@ -62,11 +63,124 @@ extension BaseCloudKitTests { ) } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createRecordInExternallySharedRecord() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) + ) + remindersListRecord.encryptedValues["title"] = "Personal" + remindersListRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() + remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1234567890) + await syncEngine.handleFetchedRecordZoneChanges( + modifications: [remindersListRecord], + deletions: [] + ) + + try { + try database.write { db in + try db.seed { + Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + } + } + }() + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) + ) + ), + syncEngine: sharedSyncEngine + ) + assertInlineSnapshot(of: batch, as: .customDump) { + """ + CKSyncEngine.RecordZoneChangeBatch( + recordsToSave: [ + [0]: CKRecord( + id: "00000000-0000-0000-0000-000000000001", + remindersListID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + title: "Get milk" + ) + ], + recordIDsToDelete: [], + atomicByZone: false + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRecordInExternallySharedRecord() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) + ) + remindersListRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() + remindersListRecord.encryptedValues["title"] = "Personal" + remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1234567890) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: UUID(1), zoneID: externalZoneID) + ) + reminderRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() + reminderRecord.encryptedValues["title"] = "Get milk" + reminderRecord.encryptedValues["remindersListID"] = UUID(1).uuidString.lowercased() + remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1234567890) + await syncEngine.handleFetchedRecordZoneChanges( + modifications: [remindersListRecord, reminderRecord], + deletions: [] + ) + + try { + try database.write { db in + try Reminder.find(UUID(1)).delete().execute(db) + } + }() + + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) + ) + ), + syncEngine: sharedSyncEngine + ) + assertInlineSnapshot(of: batch, as: .customDump) { + """ + CKSyncEngine.RecordZoneChangeBatch( + recordsToSave: [], + recordIDsToDelete: [ + [0]: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:reminders", + zoneID: CKRecordZoneID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + ], + atomicByZone: false + ) + """ + } + } } } // TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list -@Table fileprivate struct NonSyncedTable { +@Table private struct NonSyncedTable { let id: UUID } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index 66d3c3c0..66ad65f0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -7,6 +7,7 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { + @MainActor final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { #if os(macOS) && compiler(>=6.2) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 083d60f5..0aea9049 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -20,21 +20,33 @@ extension BaseCloudKitTests { CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" AFTER DELETE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN - SELECT sqlitedata_icloud_didDelete("old"."recordName"); + SELECT sqlitedata_icloud_didDelete("old"."recordName", coalesce("old"."lastKnownServerRecord", ( + SELECT "sqlitedata_icloud_metadata"."lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" IS "old"."parentRecordName") + ))); END """, [1]: """ CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName"); + SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + SELECT "sqlitedata_icloud_metadata"."lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" IS "new"."parentRecordName") + ))); END """, [2]: """ CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName"); + SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + SELECT "sqlitedata_icloud_metadata"."lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" IS "new"."parentRecordName") + ))); END """, [3]: """ @@ -123,7 +135,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -134,7 +146,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -145,7 +157,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -156,7 +168,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -167,7 +179,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", datetime('subsec') + SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -178,7 +190,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -189,7 +201,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", datetime('subsec') + SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -200,7 +212,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -211,7 +223,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", datetime('subsec') + SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -222,7 +234,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') + SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -233,7 +245,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -244,7 +256,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -255,7 +267,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", datetime('subsec') + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -266,7 +278,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", datetime('subsec') + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -277,7 +289,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", datetime('subsec') + SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -288,7 +300,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", datetime('subsec') + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -299,7 +311,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", datetime('subsec') + SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -310,7 +322,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", datetime('subsec') + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -321,7 +333,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", datetime('subsec') + SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -332,7 +344,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", datetime('subsec') + SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 60b88ab7..8d91c539 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -1,9 +1,13 @@ import CloudKit +import DependenciesTestSupport import SharingGRDB import SnapshotTesting import Testing -@Suite(.snapshots(record: .failed)) +@Suite( + .snapshots(record: .failed), + .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)) +) class BaseCloudKitTests: @unchecked Sendable { let database: any DatabaseWriter private let _syncEngine: any Sendable diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 043be001..5543aaf4 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -4,10 +4,10 @@ import CustomDump import SharingGRDBCore extension PrimaryKeyedTable { - static func recordID(for id: UUID) -> CKRecord.ID { + static func recordID(for id: UUID, zoneID: CKRecordZone.ID? = nil) -> CKRecord.ID { CKRecord.ID( recordName: self.recordName(for: id).rawValue, - zoneID: SyncEngine.defaultZone.zoneID + zoneID: zoneID ?? SyncEngine.defaultZone.zoneID ) } } @@ -45,11 +45,6 @@ final class MockSyncEngine: SyncEngineProtocol { return recordID } var recordsToSave: [CKRecord] = [] - defer { - for savedRecord in recordsToSave { - state.remove(pendingRecordZoneChanges: [.saveRecord(savedRecord.recordID)]) - } - } for recordID in savedRecordIDs { guard let record = await recordProvider(recordID) else { continue } @@ -60,6 +55,14 @@ final class MockSyncEngine: SyncEngineProtocol { else { return nil } return recordID } + defer { + for savedRecord in recordsToSave { + state.remove(pendingRecordZoneChanges: [.saveRecord(savedRecord.recordID)]) + } + for recordIDToDelete in recordIDsToDelete { + state.remove(pendingRecordZoneChanges: [.deleteRecord(recordIDToDelete)]) + } + } return CKSyncEngine.RecordZoneChangeBatch(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) } From dd0426623d3cc3fbe47db37075b0e451c99b2c8c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 24 Jun 2025 12:03:38 -0700 Subject: [PATCH 205/581] more tests --- .../CloudKit/CloudKit+StructuredQueries.swift | 40 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 6 +- .../CloudKitTests/CloudKitTests.swift | 48 ++- .../NextRecordZoneChangeBatchTests.swift | 351 ++++++++++++++---- .../CloudKitTests/RecordTypeTests.swift | 51 ++- .../CloudKitTests/SharingTests.swift | 62 +++- .../CloudKitTests/TriggerTests.swift | 6 +- .../Internal/CloudKitTestHelpers.swift | 65 +++- .../Internal/GRDBHelpers.swift | 13 + 9 files changed, 512 insertions(+), 130 deletions(-) create mode 100644 Tests/SharingGRDBTests/Internal/GRDBHelpers.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 1ee9dbbc..891f02dc 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -153,13 +153,50 @@ extension CKRecord: @retroactive CustomDumpReflectable { public var customDumpMirror: Mirror { return Mirror( self, - children: self.encryptedValues.allKeys().sorted().map { + children: [ + ("recordID", recordID as Any), + ("recordType", recordType as Any), + ("share", parent as Any), + ("parent", parent as Any), + ] + self.encryptedValues.allKeys().sorted().map { ($0, self.encryptedValues[$0] as Any) }, displayStyle: .struct ) } } + +extension CKRecord.Reference: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + return Mirror( + self, + children: [ + ("recordID", recordID as Any), + ], + displayStyle: .struct + ) + } +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +extension CKSyncEngine.RecordZoneChangeBatch: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + Mirror( + self, + children: [ + ("atomicByZone", atomicByZone as Any), + ("recordIDsToDelete", recordIDsToDelete.sorted { lhs, rhs in + lhs.recordName < rhs.recordName + } as Any), + ("recordsToSave", recordsToSave.sorted { lhs, rhs in + lhs.recordID.recordName < rhs.recordID.recordName + } as Any), + ], + displayStyle: .struct + ) + } +} + extension CKRecord.ID: @retroactive CustomDumpReflectable { public var customDumpMirror: Mirror { Mirror( @@ -172,6 +209,7 @@ extension CKRecord.ID: @retroactive CustomDumpReflectable { ) } } + extension CKRecordZone.ID: @retroactive CustomDumpReflectable { public var customDumpMirror: Mirror { Mirror( diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c428e5c8..dce22951 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -106,7 +106,7 @@ public final class SyncEngine: Sendable { self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) self.tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)).map(\.type) self.privateTables = privateTables - self.tablesByName = Dictionary(uniqueKeysWithValues: tables.map { ($0.tableName, $0) }) + self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = Dictionary( uniqueKeysWithValues: try database.read { db in try tables.map { table -> (String, [ForeignKey]) in @@ -514,8 +514,8 @@ extension SyncEngine: CKSyncEngineDelegate { recordID: recordID ) record.parent = metadata.parentRecordName.flatMap { parentRecordName in - guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) - else { return nil } +// guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) +// else { return nil } return CKRecord.Reference( recordID: CKRecord.ID( recordName: parentRecordName.rawValue, diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index c68929ad..fa49c025 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -27,6 +27,16 @@ extension BaseCloudKitTests { """ ), [1]: RecordType( + tableName: "remindersListPrivates", + schema: """ + CREATE TABLE "remindersListPrivates" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "position" INTEGER NOT NULL DEFAULT 0, + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ), + [2]: RecordType( tableName: "users", schema: """ CREATE TABLE "users" ( @@ -38,7 +48,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [2]: RecordType( + [3]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( @@ -50,7 +60,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [3]: RecordType( + [4]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( @@ -59,7 +69,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [4]: RecordType( + [5]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( @@ -69,7 +79,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [5]: RecordType( + [6]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( @@ -77,7 +87,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [6]: RecordType( + [7]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( @@ -86,7 +96,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [7]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -95,7 +105,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [8]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( @@ -111,7 +121,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDown() async throws { - try await database.write { db in + try database.syncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -120,7 +130,7 @@ extension BaseCloudKitTests { .saveRecord(RemindersList.recordID(for: UUID(1))) ]) - try await database.write { db in + try database.syncWrite { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 1) } @@ -138,7 +148,7 @@ extension BaseCloudKitTests { privateSyncEngine.assertFetchChangesScopes([.all]) sharedSyncEngine.assertFetchChangesScopes([.all]) - try await database.write { db in + try database.syncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -162,7 +172,7 @@ extension BaseCloudKitTests { ) let metadata = - try await database.write { db in + try database.syncWrite { db in try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) } #expect(metadata != nil) @@ -240,7 +250,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordUpdate() async throws { - try await database.write { db in + try database.syncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -254,7 +264,7 @@ extension BaseCloudKitTests { recordID: RemindersList.recordID(for: UUID(1)) ) let userModificationDate = try #require( - try await database.write { db in + try database.syncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -274,7 +284,7 @@ extension BaseCloudKitTests { ) let metadata = try #require( - try await database.write { db in + try database.syncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -286,7 +296,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordUpdateWithOldRecord() async throws { - try await database.write { db in + try database.syncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -299,7 +309,7 @@ extension BaseCloudKitTests { recordID: RemindersList.recordID(for: UUID(1)) ) let userModificationDate = try #require( - try await database.write { db in + try database.syncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -319,7 +329,7 @@ extension BaseCloudKitTests { ) let metadata = try #require( - try await database.write { db in + try database.syncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -330,7 +340,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordDeleted() async throws { - try await database.write { db in + try database.syncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -351,7 +361,7 @@ extension BaseCloudKitTests { try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() == 0 ) - let metadata = try await database.write { db in + let metadata = try database.syncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 86fb1097..b3fe49e7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -9,9 +9,10 @@ import Testing extension BaseCloudKitTests { @MainActor final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { - @Test func nextRecordZoneChangeBatch_NoMetadataForRecord() async throws { - privateSyncEngine.state - .add(pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: UUID(1)))]) + @Test func noMetadataForRecord() async throws { + privateSyncEngine.state.add( + pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: UUID(1)))] + ) let batch = await syncEngine._nextRecordZoneChangeBatch( SendChangesContext( @@ -21,14 +22,19 @@ extension BaseCloudKitTests { ), syncEngine: privateSyncEngine ) - #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave == []) - - #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) + assertInlineSnapshot(of: batch, as: .customDump) { + """ + CKSyncEngine.RecordZoneChangeBatch( + atomicByZone: false, + recordIDsToDelete: [], + recordsToSave: [] + ) + """ + } } - @Test func nextRecordZoneChangeBatch_NonExistentTable() async throws { - try await database.write { db in + @Test func nonExistentTable() async throws { + try database.syncWrite { db in try SyncMetadata.insert { SyncMetadata( recordType: UnrecognizedTable.tableName, @@ -37,9 +43,24 @@ extension BaseCloudKitTests { } .execute(db) } - privateSyncEngine.state - .add(pendingRecordZoneChanges: [.saveRecord(UnrecognizedTable.recordID(for: UUID(1)))]) - #expect(!privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + assertInlineSnapshot(of: privateSyncEngine.state, as: .customDump) { + """ + MockSyncEngineState( + pendingRecordZoneChanges: [ + [0]: .saveRecord( + CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:unrecognizedTables", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ) + ], + pendingDatabaseChanges: [] + ) + """ + } let batch = await syncEngine._nextRecordZoneChangeBatch( SendChangesContext( @@ -49,14 +70,19 @@ extension BaseCloudKitTests { ), syncEngine: privateSyncEngine ) - #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave == []) - - #expect(privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + assertInlineSnapshot(of: batch, as: .customDump) { + """ + CKSyncEngine.RecordZoneChangeBatch( + atomicByZone: false, + recordIDsToDelete: [], + recordsToSave: [] + ) + """ + } } - @Test func nextRecordZoneChangeBatch_DeletedRow() async throws { - try await database.write { db in + @Test func metadataRowWithNoCorrespondingRecordRow() async throws { + try database.syncWrite { db in try SyncMetadata.insert { SyncMetadata( recordType: RemindersList.tableName, @@ -65,9 +91,24 @@ extension BaseCloudKitTests { } .execute(db) } - privateSyncEngine.state - .add(pendingRecordZoneChanges: [.saveRecord(RemindersList.recordID(for: UUID(1)))]) - #expect(!privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + assertInlineSnapshot(of: privateSyncEngine.state, as: .customDump) { + """ + MockSyncEngineState( + pendingRecordZoneChanges: [ + [0]: .saveRecord( + CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ) + ], + pendingDatabaseChanges: [] + ) + """ + } let batch = await syncEngine._nextRecordZoneChangeBatch( SendChangesContext( @@ -77,23 +118,41 @@ extension BaseCloudKitTests { ), syncEngine: privateSyncEngine ) - #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave == []) - - #expect(privateSyncEngine.state.pendingRecordZoneChanges.isEmpty) + assertInlineSnapshot(of: batch, as: .customDump) { + """ + CKSyncEngine.RecordZoneChangeBatch( + atomicByZone: false, + recordIDsToDelete: [], + recordsToSave: [] + ) + """ + } } - @Test func nextRecordZoneChangeBatch_SaveRecord() async throws { - try await database.write { db in + @Test func saveRecord() async throws { + try database.syncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } } - #expect( - privateSyncEngine.state.pendingRecordZoneChanges == [ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ] - ) + assertInlineSnapshot(of: privateSyncEngine.state, as: .customDump) { + """ + MockSyncEngineState( + pendingRecordZoneChanges: [ + [0]: .saveRecord( + CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ) + ], + pendingDatabaseChanges: [] + ) + """ + } let batch = await syncEngine._nextRecordZoneChangeBatch( SendChangesContext( @@ -103,30 +162,67 @@ extension BaseCloudKitTests { ), syncEngine: privateSyncEngine ) - #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave.count == 1) - - let savedRecord = try #require(batch?.recordsToSave.first) - #expect(savedRecord.encryptedValues["title"] == "Personal") - #expect(savedRecord.recordType == RemindersList.tableName) - #expect(savedRecord.recordID == RemindersList.recordID(for: UUID(1))) - - #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) + assertInlineSnapshot(of: batch, as: .customDump) { + """ + CKSyncEngine.RecordZoneChangeBatch( + atomicByZone: false, + recordIDsToDelete: [], + recordsToSave: [ + [0]: CKRecord( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ), + recordType: "remindersLists", + share: nil, + parent: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + title: "Personal" + ) + ] + ) + """ + } } - @Test func nextRecordZoneChangeBatch_SaveRecordWithParent() async throws { - try await database.write { db in + @Test func saveRecordWithParent() async throws { + try database.syncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) } } - #expect( - Set(privateSyncEngine.state.pendingRecordZoneChanges) == [ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(1))), - ] - ) + assertInlineSnapshot(of: privateSyncEngine.state, as: .customDump) { + """ + MockSyncEngineState( + pendingRecordZoneChanges: [ + [0]: .saveRecord( + CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:reminders", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ), + [1]: .saveRecord( + CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ) + ], + pendingDatabaseChanges: [] + ) + """ + } let batch = await syncEngine._nextRecordZoneChangeBatch( SendChangesContext( @@ -139,21 +235,152 @@ extension BaseCloudKitTests { ), syncEngine: privateSyncEngine ) - #expect(batch?.recordIDsToDelete == []) - #expect(batch?.recordsToSave.count == 2) + assertInlineSnapshot(of: batch, as: .customDump) { + """ + CKSyncEngine.RecordZoneChangeBatch( + atomicByZone: false, + recordIDsToDelete: [], + recordsToSave: [ + [0]: CKRecord( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:reminders", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ), + recordType: "reminders", + share: CKReference( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ), + parent: CKReference( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ), + id: "00000000-0000-0000-0000-000000000001", + remindersListID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ), + recordType: "remindersLists", + share: nil, + parent: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + title: "Personal" + ) + ] + ) + """ + } + } - let remindersListRecord = try #require( - batch?.recordsToSave.first(where: { $0.recordType == RemindersList.tableName }) - ) - let reminderRecord = try #require( - batch?.recordsToSave.first(where: { $0.recordType == Reminder.tableName }) - ) - #expect(reminderRecord.encryptedValues["title"] == "Get milk") - #expect(reminderRecord.recordType == Reminder.tableName) - #expect(reminderRecord.recordID == Reminder.recordID(for: UUID(1))) - #expect(reminderRecord.parent?.recordID == remindersListRecord.recordID) + @Test func savePrivateRecord() async throws { + try database.syncWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + RemindersListPrivate(id: UUID(1), position: 42, remindersListID: UUID(1)) + } + } + assertInlineSnapshot(of: privateSyncEngine.state, as: .customDump) { + """ + MockSyncEngineState( + pendingRecordZoneChanges: [ + [0]: .saveRecord( + CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersListPrivates", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ), + [1]: .saveRecord( + CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ) + ], + pendingDatabaseChanges: [] + ) + """ + } - #expect(privateSyncEngine.state.pendingRecordZoneChanges == []) + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext( + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([ + RemindersList.recordID(for: UUID(1)), + Reminder.recordID(for: UUID(1)), + ]) + ) + ), + syncEngine: privateSyncEngine + ) + assertInlineSnapshot(of: batch, as: .customDump) { + """ + CKSyncEngine.RecordZoneChangeBatch( + atomicByZone: false, + recordIDsToDelete: [], + recordsToSave: [ + [0]: CKRecord( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersListPrivates", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ), + recordType: "remindersListPrivates", + share: nil, + parent: nil, + id: "00000000-0000-0000-0000-000000000001", + position: 42, + remindersListID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ), + recordType: "remindersLists", + share: nil, + parent: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + title: "Personal" + ) + ] + ) + """ + } } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 01481a3d..8405404c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -11,7 +11,7 @@ extension BaseCloudKitTests { final class RecordTypeTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() async throws { - let recordTypes = try await database.write { db in + let recordTypes = try database.syncWrite { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: recordTypes, as: .customDump) { @@ -27,6 +27,16 @@ extension BaseCloudKitTests { """ ), [1]: RecordType( + tableName: "remindersListPrivates", + schema: """ + CREATE TABLE "remindersListPrivates" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "position" INTEGER NOT NULL DEFAULT 0, + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ), + [2]: RecordType( tableName: "users", schema: """ CREATE TABLE "users" ( @@ -38,7 +48,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [2]: RecordType( + [3]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( @@ -50,7 +60,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [3]: RecordType( + [4]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( @@ -59,7 +69,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [4]: RecordType( + [5]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( @@ -69,7 +79,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [5]: RecordType( + [6]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( @@ -77,7 +87,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [6]: RecordType( + [7]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( @@ -86,7 +96,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [7]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -95,7 +105,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [8]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( @@ -111,31 +121,31 @@ extension BaseCloudKitTests { @Test func tearDown() async throws { try await syncEngine.tearDownSyncEngine() - try await database.write { db in + try database.syncWrite { db in try #expect(RecordType.all.fetchAll(db) == []) } } @Test func resetUp() async throws { - let recordTypes = try await database.write { db in + let recordTypes = try database.syncWrite { db in try RecordType.all.fetchAll(db) } try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() privateSyncEngine.assertFetchChangesScopes([.all]) sharedSyncEngine.assertFetchChangesScopes([.all]) - let recordTypesAfterReSetup = try await database.write { db in + let recordTypesAfterReSetup = try database.syncWrite { db in try RecordType.all.fetchAll(db) } expectNoDifference(recordTypes, recordTypesAfterReSetup) } @Test func migration() async throws { - let recordTypes = try await database.write { db in - try RecordType.all.fetchAll(db) + let recordTypes = try database.syncWrite { db in + try RecordType.order(by: \.tableName).fetchAll(db) } try await syncEngine.tearDownSyncEngine() - try await database.write { db in + try database.syncWrite { db in try #sql( """ ALTER TABLE "reminders" ADD COLUMN "newFeature" INTEGER NOT NULL @@ -147,13 +157,16 @@ extension BaseCloudKitTests { privateSyncEngine.assertFetchChangesScopes([.all]) sharedSyncEngine.assertFetchChangesScopes([.all]) - let recordTypesAfterMigration = try await database.write { db in - try RecordType.all.fetchAll(db) + let recordTypesAfterMigration = try database.syncWrite { db in + try RecordType.order(by: \.tableName).fetchAll(db) } - #expect(recordTypes[0...1] == recordTypesAfterMigration[0...1]) - #expect(recordTypes[3...] == recordTypesAfterMigration[3...]) + let remindersTableIndex = try #require( + recordTypesAfterMigration.firstIndex { $0.tableName == Reminder.tableName } + ) + #expect(recordTypes[0..>([]) +final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectable { + private let _pendingRecordZoneChanges = LockIsolated>([] + ) private let _pendingDatabaseChanges = LockIsolated>([]) private let fileID: StaticString private let filePath: StaticString @@ -196,6 +200,61 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol { $0.subtract(pendingDatabaseChanges) } } + + var customDumpMirror: Mirror { + return Mirror( + self, + children: [ + ( + "pendingRecordZoneChanges", + _pendingRecordZoneChanges.withValue(\.self) + .sorted(by: comparePendingRecordZoneChange) + as Any + ), + ( + "pendingDatabaseChanges", + _pendingDatabaseChanges.withValue(\.self) + .sorted(by: comparePendingDatabaseChange) as Any + ), + ], + displayStyle: .struct + ) + } +} + +private func comparePendingRecordZoneChange( + _ lhs: CKSyncEngine.PendingRecordZoneChange, + _ rhs: CKSyncEngine.PendingRecordZoneChange +) -> Bool { + switch (lhs, rhs) { + case (.saveRecord(let lhs), .saveRecord(let rhs)), + (.deleteRecord(let lhs), .deleteRecord(let rhs)): + lhs.recordName < rhs.recordName + case (.deleteRecord, .saveRecord): + true + case (.saveRecord, .deleteRecord): + false + default: + false + } +} + +private func comparePendingDatabaseChange( + _ lhs: CKSyncEngine.PendingDatabaseChange, + _ rhs: CKSyncEngine.PendingDatabaseChange +) -> Bool { + switch (lhs, rhs) { + case (.saveZone(let lhs), .saveZone(let rhs)): + lhs.zoneID.zoneName < rhs.zoneID.zoneName + case (.deleteZone(let lhs), .deleteZone(let rhs)): + lhs.zoneName < rhs.zoneName + case (.deleteZone, .saveZone): + true + case (.saveZone, .deleteZone): + false + default: + false + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift b/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift new file mode 100644 index 00000000..d50e4e9a --- /dev/null +++ b/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift @@ -0,0 +1,13 @@ +import GRDB + +extension DatabaseWriter { + func syncWrite(_ updates: (Database) throws -> T) throws -> T { + try write(updates) + } +} + +extension DatabaseReader { + func syncRead(_ updates: (Database) throws -> T) throws -> T { + try read(updates) + } +} From 2cecdca1178ac7d1c3e38e04cb40e08f5a0f0061 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 24 Jun 2025 14:45:46 -0700 Subject: [PATCH 206/581] wip --- Examples/CloudKitDemo/CountersListFeature.swift | 2 -- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 12 +++++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 12358f9c..4f692093 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -25,8 +25,6 @@ struct CountersListView: View { } } } - } header: { - Text("Local counters") } } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index dce22951..539af396 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1025,9 +1025,15 @@ extension String { @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) extension URL { - package static func metadatabase(containerIdentifier: String?) -> Self { + package static func metadatabase(containerIdentifier: String?) throws -> Self { @Dependency(\.context) var context - let base: URL = context == .live ? .applicationDirectory : .temporaryDirectory + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + let base: URL = context == .live + ? .applicationSupportDirectory + : .temporaryDirectory return base.appending( component: "\(containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" ) @@ -1086,7 +1092,7 @@ extension Database { /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize /// data. public func attachMetadatabase(containerIdentifier: String) throws { - let url = URL.metadatabase(containerIdentifier: containerIdentifier) + let url = try URL.metadatabase(containerIdentifier: containerIdentifier) let path = url.path(percentEncoded: false) try FileManager.default.createDirectory( at: .applicationSupportDirectory, From 2369d1fd2a23da28712b3f111cc1dea58d201362 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 24 Jun 2025 14:47:20 -0700 Subject: [PATCH 207/581] wip --- Examples/Reminders/RemindersDetail.swift | 20 -------------------- Examples/Reminders/RemindersLists.swift | 20 -------------------- 2 files changed, 40 deletions(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index ffccb739..70195220 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -14,7 +14,6 @@ class RemindersDetailModel: HashableObject { let detailType: DetailType var isNewReminderSheetPresented = false - var sharedRecord: SharedRecord? @ObservationIgnored @Dependency(\.defaultDatabase) private var database @ObservationIgnored @Dependency(\.defaultSyncEngine) private var syncEngine @@ -63,16 +62,6 @@ class RemindersDetailModel: HashableObject { await updateQuery() } - func shareButtonTapped() async { - guard let remindersList = detailType.remindersList - else { return } - sharedRecord = await withErrorReporting { - try await syncEngine.share(record: remindersList) { share in - share[CKShare.SystemFieldKey.title] = remindersList.title - } - } - } - private func updateQuery() async { await withErrorReporting { try await $reminderRows.load(remindersQuery, animation: .default) @@ -103,8 +92,6 @@ class RemindersDetailModel: HashableObject { case .flagged: reminder.isFlagged case .remindersList(let list): reminder.remindersListID.eq(list.id) case .scheduled: reminder.isScheduled - case .shared: - SyncMetadata.where { $0.recordPrimaryKey.eq(reminder.remindersListID) }.exists() case .tags(let tags): tag.id.ifnull(UUID(0)).in(tags.map(\.id)) case .today: reminder.isToday } @@ -144,7 +131,6 @@ class RemindersDetailModel: HashableObject { case flagged case remindersList(RemindersList) case scheduled - case shared case tags([Tag]) case today } @@ -203,9 +189,6 @@ struct RemindersDetailView: View { } } } - .sheet(item: $model.sharedRecord) { sharedRecord in - CloudSharingView(sharedRecord: sharedRecord) - } .toolbar { ToolbarItem(placement: .principal) { Text(model.detailType.navigationTitle) @@ -317,7 +300,6 @@ extension RemindersDetailModel.DetailType { case .flagged: "flagged" case .remindersList(let list): "list_\(list.id)" case .scheduled: "scheduled" - case .shared: "shared" case .tags: "tags" case .today: "today" } @@ -329,7 +311,6 @@ extension RemindersDetailModel.DetailType { case .flagged: "Flagged" case .remindersList(let list): list.title case .scheduled: "Scheduled" - case .shared: "Shared" case .tags(let tags): switch tags.count { case 0: "Tags" @@ -346,7 +327,6 @@ extension RemindersDetailModel.DetailType { case .flagged: .orange case .remindersList(let list): list.color case .scheduled: .red - case .shared: .pink case .tags: .blue case .today: .blue } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index a8b58b06..ef251396 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -44,17 +44,6 @@ class RemindersListsModel { allCount: $0.count(filter: !$0.isCompleted), flaggedCount: $0.count(filter: $0.isFlagged && !$0.isCompleted), scheduledCount: $0.count(filter: $0.isScheduled), - sharedCount: SyncMetadata.count { - $0.recordType.eq(Reminder.tableName) - && $0.parentRecordName.map { - $0.in( - SyncMetadata - .where { $0.recordType.eq(RemindersList.tableName) && $0.share.isNot(nil) } - .select(\.recordName) - ) - } - ?? false - }, todayCount: $0.count(filter: $0.isToday) ) } @@ -176,7 +165,6 @@ class RemindersListsModel { var allCount = 0 var flaggedCount = 0 var scheduledCount = 0 - var sharedCount = 0 var todayCount = 0 } @@ -246,14 +234,6 @@ struct RemindersListsView: View { ) { model.statTapped(.completed) } - ReminderGridCell( - color: .pink, - count: model.stats.sharedCount, - iconName: "square.and.arrow.up.fill", - title: "Shared" - ) { - model.statTapped(.shared) - } } } .buttonStyle(.plain) From cc793b429df064bd5f26bda1bfb432d59acbed5f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 24 Jun 2025 14:53:40 -0700 Subject: [PATCH 208/581] wip --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index b0d1e15d..732ae927 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -21,8 +21,6 @@ struct CloudKitDemoApp: App { #if canImport(UIKit) class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { - @Dependency(\.defaultSyncEngine) var syncEngine - func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil From 844794997cebd99bee05bd256d538a6c47df61f9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 24 Jun 2025 14:55:19 -0700 Subject: [PATCH 209/581] wip --- Examples/Reminders/RemindersDetail.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 70195220..b677dd32 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -14,6 +14,8 @@ class RemindersDetailModel: HashableObject { let detailType: DetailType var isNewReminderSheetPresented = false + var sharedRecord: SharedRecord? + var sharedRecord: SharedRecord? @ObservationIgnored @Dependency(\.defaultDatabase) private var database @ObservationIgnored @Dependency(\.defaultSyncEngine) private var syncEngine @@ -68,6 +70,16 @@ class RemindersDetailModel: HashableObject { } } + func shareButtonTapped() async { + guard let remindersList = detailType.remindersList + else { return } + sharedRecord = await withErrorReporting { + try await syncEngine.share(record: remindersList) { share in + share[CKShare.SystemFieldKey.title] = remindersList.title + } + } + } + private var remindersQuery: some StructuredQueriesCore.Statement { Reminder .where { @@ -189,6 +201,9 @@ struct RemindersDetailView: View { } } } + .sheet(item: $model.sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } .toolbar { ToolbarItem(placement: .principal) { Text(model.detailType.navigationTitle) From 80268867541b8c6b929858c1df0ced478b33ef19 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 24 Jun 2025 15:13:55 -0700 Subject: [PATCH 210/581] wip; --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 56 +++++++++++---------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index 732ae927..54b4228d 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -1,14 +1,15 @@ import CloudKit import SharingGRDB import SwiftUI + #if canImport -import UIKit + import UIKit #endif @main struct CloudKitDemoApp: App { -#if canImport(UIKit) - @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + #if canImport(UIKit) + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate #endif var body: some Scene { WindowGroup { @@ -20,32 +21,33 @@ struct CloudKitDemoApp: App { } #if canImport(UIKit) -class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil - ) -> Bool { - try! prepareDependencies { - $0.defaultDatabase = try appDatabase() + class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication + .LaunchOptionsKey: Any]? = nil + ) -> Bool { + try! prepareDependencies { + $0.defaultDatabase = try appDatabase() + } + return true } - return true - } - func application( - _ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions - ) -> UISceneConfiguration { - let configuration = UISceneConfiguration( - name: "Default Configuration", - sessionRole: connectingSceneSession.role - ) - configuration.delegateClass = SceneDelegate.self - return configuration + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } } -} -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? -} + class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + } #endif From e7041bba0358a78df5aafb93443dd4b43fbc0d58 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 24 Jun 2025 18:40:37 -0700 Subject: [PATCH 211/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 539af396..978a5302 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -514,8 +514,8 @@ extension SyncEngine: CKSyncEngineDelegate { recordID: recordID ) record.parent = metadata.parentRecordName.flatMap { parentRecordName in -// guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) -// else { return nil } + guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) + else { return nil } return CKRecord.Reference( recordID: CKRecord.ID( recordName: parentRecordName.rawValue, @@ -756,7 +756,14 @@ extension SyncEngine: CKSyncEngineDelegate { guard let url = share.url else { return } - let metadata = try await container.shareMetadata(for: url, shouldFetchRootRecord: true) + guard let metadata = try? await container.shareMetadata( + for: url, + shouldFetchRootRecord: true + ) + else { + // TODO: should we delete this record if it doesn't exist in the container? + return + } guard let rootRecord = metadata.rootRecord else { return } From 74e15e0cc7bab7b854a2d94e86b5d94a7be3b51a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 25 Jun 2025 09:24:06 -0700 Subject: [PATCH 212/581] wip --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 16 ++++++++++++++++ Examples/CloudKitDemo/CountersListFeature.swift | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index 54b4228d..fdcf1dc6 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -29,6 +29,13 @@ struct CloudKitDemoApp: App { ) -> Bool { try! prepareDependencies { $0.defaultDatabase = try appDatabase() + $0.defaultSyncEngine = try SyncEngine( + container: CKContainer(identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo"), + database: $0.defaultDatabase, + tables: [ + Counter.self + ] + ) } return true } @@ -49,5 +56,14 @@ struct CloudKitDemoApp: App { class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + @Dependency(\.defaultSyncEngine) var syncEngine + func windowScene( + _ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } } #endif diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 4f692093..a553228f 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -46,7 +46,9 @@ struct CountersListView: View { struct CounterRow: View { let counter: Counter + @State var sharedRecord: SharedRecord? @Dependency(\.defaultDatabase) var database + @Dependency(\.defaultSyncEngine) var syncEngine var body: some View { VStack { @@ -72,7 +74,20 @@ struct CounterRow: View { } } } + Spacer() + Button { + Task { + sharedRecord = try await syncEngine.share(record: counter) { share in + share[CKShare.SystemFieldKey.title] = "Join my counter!" + } + } + } label: { + Image(systemName: "square.and.arrow.up") + } } } + .sheet(item: $sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } } } From 5fcbe1393ec93ac1b775311423942e35da96b3ae Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 25 Jun 2025 13:35:08 -0700 Subject: [PATCH 213/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 978a5302..2ea4d099 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -203,27 +203,29 @@ public final class SyncEngine: Sendable { ) .fetchAll(db) } - let recordTypesToFetch = currentRecordTypes.filter { currentRecordType in + let recordTypesToFetch = currentRecordTypes.compactMap { currentRecordType in guard let existingRecordType = previousRecordTypes.first(where: { previousRecordType in currentRecordType.tableName == previousRecordType.tableName }) - else { return true } - return existingRecordType.schema != currentRecordType.schema + else { return (currentRecordType, isNewTable: true) } + return existingRecordType.schema == currentRecordType.schema + ? nil + : (currentRecordType, isNewTable: false) } - /* - TODO: When we detect a change in schema should save records? - TODO: Should we save records for everything in a table that is not in metadata? - */ - if !recordTypesToFetch.isEmpty { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in - for recordType in recordTypesToFetch { + for (recordType, isNewTable) in recordTypesToFetch { try RecordType .upsert { RecordType.Draft(recordType) } .execute(db) + if isNewTable { + // TODO: Upload everything + } else { + // TODO: Fetch everything + } } } } From 54f96bb3827f035206359b1f717614f9c3a4b07a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 25 Jun 2025 13:55:48 -0700 Subject: [PATCH 214/581] wip --- Examples/CloudKitDemo/Schema.swift | 37 ++++++++++++++++--- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 9 ++++- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 59e84ba3..aa011a0a 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog import SharingGRDB @Table @@ -8,15 +9,37 @@ struct Counter: Identifiable { } func appDatabase() throws -> any DatabaseWriter { - let path = URL.documentsDirectory.appendingPathComponent("db.sqlite").path() + @Dependency(\.context) var context + let database: any DatabaseWriter var configuration = Configuration() - configuration.foreignKeysEnabled = false + configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in - db.trace { - print($0.expandedDescription) - } + #if DEBUG + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") + db.trace(options: .profile) { + if context == .live { + logger.debug("\($0.expandedDescription)") + } else { + print("\($0.expandedDescription)") + } + } + #endif + } + if context == .preview { + database = try DatabaseQueue(configuration: configuration) + } else { + let path = + context == .live + ? URL.documentsDirectory.appending(component: "db.sqlite").path() + : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + logger.debug( + """ + App database + open "\(path)" + """ + ) + database = try DatabasePool(path: path, configuration: configuration) } - let database = try DatabasePool(path: path, configuration: configuration) var migrator = DatabaseMigrator() #if DEBUG @@ -35,3 +58,5 @@ func appDatabase() throws -> any DatabaseWriter { return database } + +private let logger = Logger(subsystem: "CloudKitDemo", category: "Database") diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 2ea4d099..ab1a294d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -222,7 +222,14 @@ public final class SyncEngine: Sendable { .upsert { RecordType.Draft(recordType) } .execute(db) if isNewTable { - // TODO: Upload everything + if let table = tablesByName[recordType.tableName] { + func open>(_: T.Type) throws { + try T + .update { $0.primaryKey = $0.primaryKey } + .execute(db) + } + try open(table) + } } else { // TODO: Fetch everything } From eb9fc6947138f523f7261aa8df8d1a13e32d9aac Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 25 Jun 2025 14:17:50 -0700 Subject: [PATCH 215/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../CloudKitTests/NewTableSyncTests.swift | 110 ++++++++++++++++++ .../NextRecordZoneChangeBatchTests.swift | 10 +- .../CloudKitTests/SharingTests.swift | 10 +- .../Internal/BaseCloudKitTests.swift | 13 ++- 5 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 891f02dc..1c93175c 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -156,7 +156,7 @@ extension CKRecord: @retroactive CustomDumpReflectable { children: [ ("recordID", recordID as Any), ("recordType", recordType as Any), - ("share", parent as Any), + ("share", share as Any), ("parent", parent as Any), ] + self.encryptedValues.allKeys().sorted().map { ($0, self.encryptedValues[$0] as Any) diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift new file mode 100644 index 00000000..f50bd4bb --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -0,0 +1,110 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { + init() async throws { + try await super.init( + seeds: [ + RemindersList(id: UUID(1), title: "Personal"), + Reminder(id: UUID(1), title: "Write blog post", remindersListID: UUID(1)) + ] + ) + } + + @Test func initialSync() async throws { + let metadata = try database.syncRead { db in + try SyncMetadata.all.fetchAll(db) + } + assertInlineSnapshot(of: metadata, as: .customDump) { + """ + [ + [0]: SyncMetadata( + recordType: "reminders", + recordName: SyncMetadata.RecordName( + recordType: "reminders", + id: UUID(00000000-0000-0000-0000-000000000001) + ), + parentRecordName: SyncMetadata.RecordName( + recordType: "remindersLists", + id: UUID(00000000-0000-0000-0000-000000000001) + ), + lastKnownServerRecord: nil, + share: nil, + userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: SyncMetadata( + recordType: "remindersLists", + recordName: SyncMetadata.RecordName( + recordType: "remindersLists", + id: UUID(00000000-0000-0000-0000-000000000001) + ), + parentRecordName: nil, + lastKnownServerRecord: nil, + share: nil, + userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + """ + } + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext(), syncEngine: privateSyncEngine + ) + assertInlineSnapshot(of: batch, as: .customDump) { + """ + CKSyncEngine.RecordZoneChangeBatch( + atomicByZone: false, + recordIDsToDelete: [], + recordsToSave: [ + [0]: CKRecord( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:reminders", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ), + recordType: "reminders", + share: nil, + parent: CKReference( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ) + ), + id: "00000000-0000-0000-0000-000000000001", + remindersListID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + title: "Write blog post" + ), + [1]: CKRecord( + recordID: CKRecordID( + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + zoneID: CKRecordZoneID( + zoneName: "co.pointfree.SQLiteData.defaultZone", + ownerName: "__defaultOwner__" + ) + ), + recordType: "remindersLists", + share: nil, + parent: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + title: "Personal" + ) + ] + ) + """ + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index b3fe49e7..5a2e8dd5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -250,15 +250,7 @@ extension BaseCloudKitTests { ) ), recordType: "reminders", - share: CKReference( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ), + share: nil, parent: CKReference( recordID: CKRecordID( recordName: "00000000-0000-0000-0000-000000000001:remindersLists", diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index cdacbb76..77fbc608 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -112,15 +112,7 @@ extension BaseCloudKitTests { ) ), recordType: "reminders", - share: CKReference( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - ) - ), + share: nil, parent: CKReference( recordID: CKRecordID( recordName: "00000000-0000-0000-0000-000000000001:remindersLists", diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 8d91c539..d5d51bef 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -29,14 +29,23 @@ class BaseCloudKitTests: @unchecked Sendable { _sharedSyncEngine as! MockSyncEngine } + typealias SendablePrimaryKeyedTable = PrimaryKeyedTable & Sendable + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { + init(seeds: [any SendablePrimaryKeyedTable] = []) async throws { let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" let database = try SharingGRDBTests.database(containerIdentifier: testContainerIdentifier) + self.database = database + try { [seeds] in + try database.write { db in + try db.seed { + seeds + } + } + }() let privateSyncEngine = MockSyncEngine(scope: .private, state: MockSyncEngineState()) let sharedSyncEngine = MockSyncEngine(scope: .shared, state: MockSyncEngineState()) - self.database = database _privateSyncEngine = privateSyncEngine _sharedSyncEngine = sharedSyncEngine _syncEngine = try SyncEngine( From 8ce2205390884af0d5232b6bf16bbbbfbd1a8af9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 25 Jun 2025 14:21:44 -0700 Subject: [PATCH 216/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ab1a294d..da2de5ea 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -851,6 +851,7 @@ extension SyncEngine: CKSyncEngineDelegate { let userModificationDate, userModificationDate > record.userModificationDate ?? .distantPast else { + // TODO: This should be fetched early and held onto (like 'ForeignKey') let columnNames = try database.read { db in try SQLQueryExpression( """ @@ -872,9 +873,6 @@ extension SyncEngine: CKSyncEngineDelegate { return (try? asset.fileURL.map { try Data(contentsOf: $0) })? .queryFragment ?? "NULL" } else { - if encryptedValues[columnName] == nil { - print("!!!") - } return encryptedValues[columnName]?.queryFragment ?? "NULL" } } From e905fbd2c121ce7b91c8e6ec2e6ba9072b899738 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 25 Jun 2025 14:55:37 -0700 Subject: [PATCH 217/581] Fetch on schema change. --- .../CloudKit/CloudKit+StructuredQueries.swift | 1 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 93 +++++++++++++------ .../CloudKitTests/NewTableSyncTests.swift | 2 +- .../CloudKitTests/SyncEngineSetUpTests.swift | 18 ++++ 4 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 1c93175c..11630e8f 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -34,6 +34,7 @@ extension CKRecord { } extension CKShare { + // TODO: Confirm that it's not possible to name this 'DataRepresentation' public struct ShareDataRepresentation: QueryBindable, QueryRepresentable { public let queryOutput: CKShare diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index da2de5ea..5ba5018f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -117,10 +117,11 @@ public final class SyncEngine: Sendable { } } ) - try setUpSyncEngine( + _ = try setUpSyncEngine( database: database, - metadatabase: metadatabase, - shouldFetchChanges: true + metadatabase: metadatabase +// , +// shouldFetchChanges: true ) } @@ -129,21 +130,23 @@ public final class SyncEngine: Sendable { } package func setUpSyncEngine() async throws { - try setUpSyncEngine( + try await setUpSyncEngine( database: database, - metadatabase: metadatabase, - shouldFetchChanges: false - ) - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await fetchChanges() - } + metadatabase: metadatabase +// , +// shouldFetchChanges: false + )?.value +// await withErrorReporting(.sqliteDataCloudKitFailure) { +// try await fetchChanges() +// } } nonisolated func setUpSyncEngine( database: any DatabaseWriter, - metadatabase: any DatabaseReader, - shouldFetchChanges: Bool - ) throws { + metadatabase: any DatabaseReader +// , +// shouldFetchChanges: Bool + ) throws -> Task? { try database.write { db in let hasAttachedMetadatabase: Bool = try SQLQueryExpression( @@ -221,26 +224,62 @@ public final class SyncEngine: Sendable { try RecordType .upsert { RecordType.Draft(recordType) } .execute(db) - if isNewTable { - if let table = tablesByName[recordType.tableName] { - func open>(_: T.Type) throws { - try T - .update { $0.primaryKey = $0.primaryKey } - .execute(db) - } - try open(table) + if isNewTable, let table = tablesByName[recordType.tableName] { + func open>(_: T.Type) throws { + try T + .update { $0.primaryKey = $0.primaryKey } + .execute(db) } - } else { - // TODO: Fetch everything + try open(table) } } } } - if shouldFetchChanges { - Task { - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await fetchChanges() + + return Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + // TODO: comment this out and see if things still work + try await fetchChanges() + try await fetchChangesFromSchemaChange( + recordTypesChanged: recordTypesToFetch.filter { !$0.isNewTable }.map(\.0) + ) + } + } + } + + return nil + } + + // TODO: Fetch everything + private func fetchChangesFromSchemaChange(recordTypesChanged: [RecordType]) async throws { + let lastKnownServerRecords = try { + try metadatabase.read { db in + try SyncMetadata + .where { + $0.recordType.in(recordTypesChanged.map(\.tableName)) && $0.lastKnownServerRecord.isNot(nil) + } + .select { + SQLQueryExpression( + "\($0.lastKnownServerRecord)", + as: CKRecord.DataRepresentation.self + ) } + .fetchAll(db) + } + }() + + let recordIDs = lastKnownServerRecords.map(\.recordID) + let recordIDsByDatabase = Dictionary(grouping: recordIDs) { container.database(for: $0) } + for (database, recordIDs) in recordIDsByDatabase { + let results = try await database.records(for: recordIDs) + for (_, result) in results { + switch result { + case .success(let record): + upsertFromServerRecord(record) + break + case .failure(let error): + reportIssue(error) + break } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index f50bd4bb..732a1b0c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -20,7 +20,7 @@ extension BaseCloudKitTests { @Test func initialSync() async throws { let metadata = try database.syncRead { db in - try SyncMetadata.all.fetchAll(db) + try SyncMetadata.all.order(by: \.primaryKey).fetchAll(db) } assertInlineSnapshot(of: metadata, as: .customDump) { """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift new file mode 100644 index 00000000..3330b147 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -0,0 +1,18 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class SetUpTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func schemaChange() async throws { + try await syncEngine.tearDownSyncEngine() + + } + } +} From ad2e3acbaae605a4611739f9aa79e15b79f93479 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 25 Jun 2025 17:38:36 -0700 Subject: [PATCH 218/581] wip --- Examples/CloudKitDemo/Schema.swift | 7 ++ .../CloudKit/CloudContainer.swift | 48 ++++++++ .../CloudKit/CloudDatabase.swift | 93 +++++++++++++++ .../CloudKit/CloudKit+Helpers.swift | 26 ----- .../CloudKit/CloudKitSharing.swift | 24 +++- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 107 ++++++++---------- .../CloudKit/SyncEngineProtocol+Live.swift | 4 + .../CloudKit/SyncEngineProtocol.swift | 7 +- .../SharingGRDBCore/CloudKit/Triggers.swift | 2 + .../CloudKitTests/CloudKitTests.swift | 20 ++-- .../CloudKitTests/RecordTypeTests.swift | 22 ++-- .../CloudKitTests/SyncEngineSetUpTests.swift | 73 +++++++++++- .../Internal/BaseCloudKitTests.swift | 25 ++-- .../Internal/CloudKitTestHelpers.swift | 106 +++++++++++++++-- Tests/SharingGRDBTests/Internal/Schema.swift | 20 ++-- 15 files changed, 446 insertions(+), 138 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/CloudContainer.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift delete mode 100644 Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index aa011a0a..bda2337a 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -54,6 +54,13 @@ func appDatabase() throws -> any DatabaseWriter { """) .execute(db) } + migrator.registerMigration("Alter table") { db in + try #sql(""" + ALTER TABLE "counters" + ADD COLUMN "newFeature" TEXT + """) + .execute(db) + } try migrator.migrate(database) return database diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift new file mode 100644 index 00000000..22d4689c --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -0,0 +1,48 @@ +#if canImport(CloudKit) +import CloudKit + +// TODO: make AnyObject +package protocol CloudContainerProtocol: Equatable, Hashable, Sendable { + var sharedDatabase: any CloudDatabase { get } + var privateDatabase: any CloudDatabase { get } + func database(for recordID: CKRecord.ID) -> any CloudDatabase + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata +} + +extension CloudContainerProtocol { + package func database(for recordID: CKRecord.ID) -> any CloudDatabase { + if recordID.zoneID.ownerName != CKCurrentUserDefaultName { + print("?!?!?!") + } + return recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? privateDatabase + : sharedDatabase + } +} + +extension CKContainer: CloudContainerProtocol { + package var sharedDatabase: any CloudDatabase { + sharedCloudDatabase + } + + package var privateDatabase: any CloudDatabase { + privateCloudDatabase + } + + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + package func shareMetadata( + for url: URL, + shouldFetchRootRecord: Bool = false + ) async throws -> CKShare.Metadata { + try await withUnsafeThrowingContinuation { continuation in + let operation = CKFetchShareMetadataOperation(shareURLs: [url]) + operation.shouldFetchRootRecord = true + operation.perShareMetadataResultBlock = { url, result in + continuation.resume(with: result) + } + add(operation) + } + } +} +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift new file mode 100644 index 00000000..fafaa80f --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift @@ -0,0 +1,93 @@ +#if canImport(CloudKit) +import CloudKit + +package protocol CloudDatabase: AnyObject, Hashable, Sendable { + func record(for recordID: CKRecord.ID) async throws -> CKRecord + + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func records( + for ids: [CKRecord.ID] + ) async throws -> [CKRecord.ID : Result] + + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy, + atomically: Bool + ) async throws -> ( + saveResults: [CKRecord.ID : Result], + deleteResults: [CKRecord.ID : Result] + ) +} + +final class AnyCloudDatabase: CloudDatabase { + let rawValue: any CloudDatabase + init(_ rawValue: any CloudDatabase) { + self.rawValue = rawValue + } + func record(for recordID: CKRecord.ID) async throws -> CKRecord { + try await rawValue.record(for: recordID) + } + + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func records( + for ids: [CKRecord.ID] + ) async throws -> [CKRecord.ID : Result] { + try await rawValue.records(for: ids) + } + + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy, + atomically: Bool + ) async throws -> ( + saveResults: [CKRecord.ID : Result], + deleteResults: [CKRecord.ID : Result] + ) { + try await rawValue.modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete, + savePolicy: savePolicy, + atomically: atomically + ) + } + + static func == (lhs: AnyCloudDatabase, rhs: AnyCloudDatabase) -> Bool { + lhs.rawValue === rhs.rawValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(rawValue)) + } +} + +extension CloudDatabase { + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID] + ) async throws -> ( + saveResults: [CKRecord.ID : Result], + deleteResults: [CKRecord.ID : Result] + ) { + try await modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete, + savePolicy: .ifServerRecordUnchanged, + atomically: true + ) + } +} + +extension CKDatabase: CloudDatabase { + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + package func records( + for ids: [CKRecord.ID] + ) async throws -> [CKRecord.ID : Result] { + try await records(for: ids, desiredKeys: nil) + } +} +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift deleted file mode 100644 index 2ea6b5cf..00000000 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+Helpers.swift +++ /dev/null @@ -1,26 +0,0 @@ -#if canImport(CloudKit) -import CloudKit - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKContainer { - func shareMetadata( - for url: URL, - shouldFetchRootRecord: Bool = false - ) async throws -> CKShare.Metadata { - try await withUnsafeThrowingContinuation { continuation in - let operation = CKFetchShareMetadataOperation(shareURLs: [url]) - operation.shouldFetchRootRecord = true - operation.perShareMetadataResultBlock = { url, result in - continuation.resume(with: result) - } - add(operation) - } - } - - func database(for recordID: CKRecord.ID) -> CKDatabase { - recordID.zoneID.ownerName == CKCurrentUserDefaultName - ? privateCloudDatabase - : sharedCloudDatabase - } -} -#endif diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 3e7dfec9..6aa3e2ed 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -8,10 +8,25 @@ import SwiftUI #endif public struct SharedRecord: Hashable, Identifiable, Sendable { - public let container: CKContainer + let container: any CloudContainerProtocol public let share: CKShare public var id: CKRecord.ID { share.recordID } + + public static func == (lhs: Self, rhs: Self) -> Bool { + func open(_ lhs: T) -> Bool { + lhs == (rhs.container as? T) + } + return open(lhs.container) && lhs.share == rhs.share + } + + public func hash(into hasher: inout Hasher) { + func open(_ container: some Hashable) { + hasher.combine(container) + } + open(container) + hasher.combine(share) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -78,7 +93,7 @@ extension SyncEngine { configure(sharedRecord) // TODO: We are getting an "client oplock error updating record" error in the logs when // creating new shares / editing existing shares. - _ = try await container.privateCloudDatabase.modifyRecords( + _ = try await container.privateDatabase.modifyRecords( saving: [sharedRecord, rootRecord], deleting: [] ) @@ -89,7 +104,10 @@ extension SyncEngine { .execute(db) } - return SharedRecord(container: container, share: sharedRecord) + return SharedRecord( + container: container, + share: sharedRecord + ) } public func acceptShare(metadata: CKShare.Metadata) async throws { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5ba5018f..1690a788 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -20,7 +20,7 @@ public final class SyncEngine: Sendable { let defaultSyncEngines: @Sendable (any DatabaseReader, SyncEngine) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) - let _container: any Sendable + let container: any CloudContainerProtocol public convenience init( container: CKContainer, @@ -59,17 +59,23 @@ public final class SyncEngine: Sendable { tables: tables, privateTables: privateTables ) + _ = try setUpSyncEngine( + database: database, + metadatabase: metadatabase + ) } package convenience init( + container: any CloudContainerProtocol, privateSyncEngine: any SyncEngineProtocol, sharedSyncEngine: any SyncEngineProtocol, database: any DatabaseWriter, metadatabaseURL: URL, tables: [any PrimaryKeyedTable.Type], privateTables: [any PrimaryKeyedTable.Type] = [] - ) throws { + ) async throws { try self.init( + container: container, defaultSyncEngines: { _, _ in (privateSyncEngine, sharedSyncEngine) }, database: database, logger: Logger(.disabled), @@ -77,10 +83,11 @@ public final class SyncEngine: Sendable { tables: tables, privateTables: privateTables ) + try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value } private init( - container: (any Sendable)? = Void?.none, + container: any CloudContainerProtocol, defaultSyncEngines: @escaping @Sendable ( any DatabaseReader, SyncEngine @@ -99,7 +106,7 @@ public final class SyncEngine: Sendable { Foreign key support must be disabled to synchronize with CloudKit. """ ) - self._container = container + self.container = container self.defaultSyncEngines = defaultSyncEngines self.database = database self.logger = logger @@ -117,47 +124,27 @@ public final class SyncEngine: Sendable { } } ) - _ = try setUpSyncEngine( - database: database, - metadatabase: metadatabase -// , -// shouldFetchChanges: true - ) - } - - var container: CKContainer { - _container as! CKContainer } package func setUpSyncEngine() async throws { - try await setUpSyncEngine( - database: database, - metadatabase: metadatabase -// , -// shouldFetchChanges: false - )?.value -// await withErrorReporting(.sqliteDataCloudKitFailure) { -// try await fetchChanges() -// } + try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value } nonisolated func setUpSyncEngine( database: any DatabaseWriter, metadatabase: any DatabaseReader -// , -// shouldFetchChanges: Bool ) throws -> Task? { try database.write { db in let hasAttachedMetadatabase: Bool = - try SQLQueryExpression( + try SQLQueryExpression( """ SELECT count(*) FROM pragma_database_list WHERE "name" = \(bind: String.sqliteDataCloudKitSchemaName) """, as: Int.self - ) - .fetchOne(db) == 1 + ) + .fetchOne(db) == 1 if !hasAttachedMetadatabase { try SQLQueryExpression( """ @@ -213,41 +200,40 @@ public final class SyncEngine: Sendable { }) else { return (currentRecordType, isNewTable: true) } return existingRecordType.schema == currentRecordType.schema - ? nil - : (currentRecordType, isNewTable: false) + ? nil + : (currentRecordType, isNewTable: false) } - if !recordTypesToFetch.isEmpty { - withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in - for (recordType, isNewTable) in recordTypesToFetch { - try RecordType - .upsert { RecordType.Draft(recordType) } - .execute(db) - if isNewTable, let table = tablesByName[recordType.tableName] { - func open>(_: T.Type) throws { - try T - .update { $0.primaryKey = $0.primaryKey } - .execute(db) - } - try open(table) + guard !recordTypesToFetch.isEmpty + else { return nil } + + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + for (recordType, isNewTable) in recordTypesToFetch { + try RecordType + .upsert { RecordType.Draft(recordType) } + .execute(db) + if isNewTable, let table = tablesByName[recordType.tableName] { + func open>(_: T.Type) throws { + try T + .update { $0.primaryKey = $0.primaryKey } + .execute(db) } + try open(table) } } } + } - return Task { - await withErrorReporting(.sqliteDataCloudKitFailure) { - // TODO: comment this out and see if things still work - try await fetchChanges() - try await fetchChangesFromSchemaChange( - recordTypesChanged: recordTypesToFetch.filter { !$0.isNewTable }.map(\.0) - ) - } + return Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + // TODO: comment this out and see if things still work + try await fetchChanges() + try await fetchChangesFromSchemaChange( + recordTypesChanged: recordTypesToFetch.filter { !$0.isNewTable }.map(\.0) + ) } } - - return nil } // TODO: Fetch everything @@ -269,7 +255,9 @@ public final class SyncEngine: Sendable { }() let recordIDs = lastKnownServerRecords.map(\.recordID) - let recordIDsByDatabase = Dictionary(grouping: recordIDs) { container.database(for: $0) } + let recordIDsByDatabase = Dictionary(grouping: recordIDs) { + AnyCloudDatabase(container.database(for: $0)) + } for (database, recordIDs) in recordIDsByDatabase { let results = try await database.records(for: recordIDs) for (_, result) in results { @@ -917,12 +905,15 @@ extension SyncEngine: CKSyncEngineDelegate { } .joined(separator: ", ") ) - func open(_: T.Type) { - query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET") + func open(_: T.Type) -> String { + T.columns.primaryKey.name } - open(table) + let primaryKeyName = open(table) + query.append(") ON CONFLICT(\(quote: primaryKeyName)) DO UPDATE SET ") + query.append( columnNames + .filter { columnName in columnName != primaryKeyName } .map { """ \(quote: $0) = "excluded".\(quote: $0) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift index 5db9ea32..33e60d42 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift @@ -3,6 +3,10 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine: SyncEngineProtocol { + package var cloudDatabase: any CloudDatabase { + database + } + package var scope: CKDatabase.Scope { database.databaseScope } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index c84624cf..27173010 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -4,11 +4,14 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package protocol SyncEngineProtocol: AnyObject, Sendable { associatedtype State: CKSyncEngineStateProtocol - func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws - var state: State { get } + + var cloudDatabase: any CloudDatabase { get } var scope: CKDatabase.Scope { get } + var state: State { get } + func acceptShare(metadata: ShareMetadata) async throws func cancelOperations() async + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws func recordZoneChangeBatch( pendingChanges: [CKSyncEngine.PendingRecordZoneChange], recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 33e18e9f..784e0aa7 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -14,6 +14,7 @@ extension PrimaryKeyedTable { fileprivate static func afterInsert(parentForeignKey: ForeignKey?) -> TemporaryTrigger { createTemporaryTrigger( "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", + ifNotExists: true, after: .insert { new in SyncMetadata.insert(new: new, parentForeignKey: parentForeignKey) } ) } @@ -21,6 +22,7 @@ extension PrimaryKeyedTable { fileprivate static func afterUpdate(parentForeignKey: ForeignKey?) -> TemporaryTrigger { createTemporaryTrigger( "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", + ifNotExists: true, after: .update { _, new in SyncMetadata.insert(new: new, parentForeignKey: parentForeignKey) } ) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index fa49c025..2b936c79 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -21,7 +21,7 @@ extension BaseCloudKitTests { tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '' ) STRICT """ @@ -30,7 +30,7 @@ extension BaseCloudKitTests { tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "position" INTEGER NOT NULL DEFAULT 0, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT @@ -40,7 +40,7 @@ extension BaseCloudKitTests { tableName: "users", schema: """ CREATE TABLE "users" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "name" TEXT NOT NULL DEFAULT '', "parentUserID" TEXT, @@ -52,7 +52,7 @@ extension BaseCloudKitTests { tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '', "remindersListID" TEXT NOT NULL, @@ -64,7 +64,7 @@ extension BaseCloudKitTests { tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '' ) STRICT """ @@ -73,7 +73,7 @@ extension BaseCloudKitTests { tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT @@ -83,7 +83,7 @@ extension BaseCloudKitTests { tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()) + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) ) STRICT """ ), @@ -91,7 +91,7 @@ extension BaseCloudKitTests { tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT """ @@ -100,7 +100,7 @@ extension BaseCloudKitTests { tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """ @@ -109,7 +109,7 @@ extension BaseCloudKitTests { tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 8405404c..ede1011a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -21,7 +21,7 @@ extension BaseCloudKitTests { tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '' ) STRICT """ @@ -30,7 +30,7 @@ extension BaseCloudKitTests { tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "position" INTEGER NOT NULL DEFAULT 0, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT @@ -40,7 +40,7 @@ extension BaseCloudKitTests { tableName: "users", schema: """ CREATE TABLE "users" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "name" TEXT NOT NULL DEFAULT '', "parentUserID" TEXT, @@ -52,7 +52,7 @@ extension BaseCloudKitTests { tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '', "remindersListID" TEXT NOT NULL, @@ -64,7 +64,7 @@ extension BaseCloudKitTests { tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '' ) STRICT """ @@ -73,7 +73,7 @@ extension BaseCloudKitTests { tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT @@ -83,7 +83,7 @@ extension BaseCloudKitTests { tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()) + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) ) STRICT """ ), @@ -91,7 +91,7 @@ extension BaseCloudKitTests { tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT """ @@ -100,7 +100,7 @@ extension BaseCloudKitTests { tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """ @@ -109,7 +109,7 @@ extension BaseCloudKitTests { tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """ @@ -172,7 +172,7 @@ extension BaseCloudKitTests { tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '', "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index 3330b147..fadd4b76 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -11,8 +11,77 @@ extension BaseCloudKitTests { final class SetUpTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func schemaChange() async throws { - try await syncEngine.tearDownSyncEngine() - + try database.syncWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: UUID(2), title: "Business") + Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + } + } + _ = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext(), + syncEngine: privateSyncEngine + ) + + let personalListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: UUID(1)) + ) + personalListRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() + personalListRecord.encryptedValues["title"] = "Personal" + personalListRecord.encryptedValues["position"] = 1 + personalListRecord.userModificationDate = Date() + let businessListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: UUID(2)) + ) + businessListRecord.encryptedValues["id"] = UUID(2).uuidString.lowercased() + businessListRecord.encryptedValues["title"] = "Business" + businessListRecord.encryptedValues["position"] = 2 + businessListRecord.userModificationDate = Date() + _ = await privateDatabase.modifyRecords( + saving: [personalListRecord, businessListRecord], + deleting: [], + savePolicy: .ifServerRecordUnchanged, + atomically: true + ) + + try database.syncWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + """ + ) + .execute(db) + } + + try await syncEngine.setUpSyncEngine() + let batch = await syncEngine._nextRecordZoneChangeBatch( + SendChangesContext(), + syncEngine: privateSyncEngine + ) + #expect(batch == nil) + privateSyncEngine.assertFetchChangesScopes([.all]) + sharedSyncEngine.assertFetchChangesScopes([.all]) + + let remindersLists = try database.syncRead { db in + try MigratedRemindersList.order(by: \.id).fetchAll(db) + } + expectNoDifference( + remindersLists, + [ + MigratedRemindersList(id: UUID(1), title: "Personal", position: 1), + MigratedRemindersList(id: UUID(2), title: "Business", position: 2), + ] + ) } } } + +@Table("remindersLists") +struct MigratedRemindersList: Equatable, Identifiable { + let id: UUID + var title = "" + var position = 0 +} diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index d5d51bef..8a108422 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -11,6 +11,8 @@ import Testing class BaseCloudKitTests: @unchecked Sendable { let database: any DatabaseWriter private let _syncEngine: any Sendable + let sharedDatabase = MockCloudDatabase() + let privateDatabase = MockCloudDatabase() private let _privateSyncEngine: any Sendable private let _sharedSyncEngine: any Sendable @@ -39,16 +41,26 @@ class BaseCloudKitTests: @unchecked Sendable { self.database = database try { [seeds] in try database.write { db in - try db.seed { - seeds - } + try db.seed { seeds } } }() - let privateSyncEngine = MockSyncEngine(scope: .private, state: MockSyncEngineState()) - let sharedSyncEngine = MockSyncEngine(scope: .shared, state: MockSyncEngineState()) + let privateSyncEngine = MockSyncEngine( + cloudDatabase: privateDatabase, + scope: .private, + state: MockSyncEngineState() + ) + let sharedSyncEngine = MockSyncEngine( + cloudDatabase: sharedDatabase, + scope: .shared, + state: MockSyncEngineState() + ) _privateSyncEngine = privateSyncEngine _sharedSyncEngine = sharedSyncEngine - _syncEngine = try SyncEngine( + _syncEngine = try await SyncEngine( + container: MockCloudContainer( + privateDatabase: privateSyncEngine.cloudDatabase, + sharedDatabase: sharedSyncEngine.cloudDatabase + ), privateSyncEngine: privateSyncEngine, sharedSyncEngine: sharedSyncEngine, database: database, @@ -68,7 +80,6 @@ class BaseCloudKitTests: @unchecked Sendable { RemindersListPrivate.self ] ) - try await Task.sleep(for: .seconds(0.1)) privateSyncEngine.assertFetchChangesScopes([.all]) sharedSyncEngine.assertFetchChangesScopes([.all]) } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index e65e54f5..15010b00 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -14,15 +14,26 @@ extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) final class MockSyncEngine: SyncEngineProtocol { + private let _cloudDatabase: MockCloudDatabase private let _state: LockIsolated private let _fetchChangesScopes = LockIsolated>([]) private let _acceptedShareMetadata = LockIsolated>([]) - let scope: CKDatabase.Scope - init(scope: CKDatabase.Scope, state: MockSyncEngineState) { + + init( + cloudDatabase: MockCloudDatabase, + scope: CKDatabase.Scope, + state: MockSyncEngineState + ) { + _cloudDatabase = cloudDatabase self.scope = scope self._state = LockIsolated(state) } + + var cloudDatabase: any CloudDatabase { + _cloudDatabase + } + var state: MockSyncEngineState { _state.withValue(\.self) } @@ -55,14 +66,19 @@ final class MockSyncEngine: SyncEngineProtocol { else { return nil } return recordID } - defer { - for savedRecord in recordsToSave { - state.remove(pendingRecordZoneChanges: [.saveRecord(savedRecord.recordID)]) - } - for recordIDToDelete in recordIDsToDelete { - state.remove(pendingRecordZoneChanges: [.deleteRecord(recordIDToDelete)]) - } + + for savedRecord in recordsToSave { + state.remove(pendingRecordZoneChanges: [.saveRecord(savedRecord.recordID)]) } + for recordIDToDelete in recordIDsToDelete { + state.remove(pendingRecordZoneChanges: [.deleteRecord(recordIDToDelete)]) + } + _ = await _cloudDatabase.modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete, + savePolicy: .ifServerRecordUnchanged, + atomically: true + ) return CKSyncEngine.RecordZoneChangeBatch( recordsToSave: recordsToSave, @@ -222,6 +238,78 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl } } +actor MockCloudDatabase: CloudDatabase { + var storage: [CKRecord.ID: CKRecord] = [:] + + struct RecordNotFound: Error {} + + func record(for recordID: CKRecord.ID) throws -> CKRecord { + guard let record = storage[recordID] + else { throw RecordNotFound() } + return record + } + + func records(for ids: [CKRecord.ID]) throws -> [CKRecord.ID : Result] { + var results: [CKRecord.ID : Result] = [:] + for id in ids { + results[id] = Result { try record(for: id) } + } + return results + } + + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy, + atomically: Bool + ) -> ( + saveResults: [CKRecord.ID : Result], + deleteResults: [CKRecord.ID : Result] + ) { + for recordToSave in recordsToSave { + storage[recordToSave.recordID] = recordToSave + } + return ( + saveResults: Dictionary( + uniqueKeysWithValues: recordsToSave.map { ($0.recordID, .success($0)) } + ), + deleteResults: Dictionary( + uniqueKeysWithValues: recordIDsToDelete.map { ($0, .success(())) } + ) + ) + } + + nonisolated static func == (lhs: MockCloudDatabase, rhs: MockCloudDatabase) -> Bool { + lhs === rhs + } + + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +final class MockCloudContainer: CloudContainerProtocol { + let privateDatabase: any CloudDatabase + let sharedDatabase: any CloudDatabase + + init(privateDatabase: any CloudDatabase, sharedDatabase: any CloudDatabase) { + self.privateDatabase = privateDatabase + self.sharedDatabase = sharedDatabase + } + + func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata { + fatalError() + } + + static func == (lhs: MockCloudContainer, rhs: MockCloudContainer) -> Bool { + lhs === rhs + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + private func comparePendingRecordZoneChange( _ lhs: CKSyncEngine.PendingRecordZoneChange, _ rhs: CKSyncEngine.PendingRecordZoneChange diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index fa12b374..dd47f9d5 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -62,7 +62,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersLists" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '' ) STRICT """ @@ -71,7 +71,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersListPrivates" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "position" INTEGER NOT NULL DEFAULT 0, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT @@ -81,7 +81,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "users" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "name" TEXT NOT NULL DEFAULT '', "parentUserID" TEXT, @@ -93,7 +93,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '', "remindersListID" TEXT NOT NULL, @@ -105,7 +105,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "tags" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL DEFAULT '' ) STRICT """ @@ -114,7 +114,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "reminderTags" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT @@ -123,27 +123,27 @@ func database(containerIdentifier: String) throws -> DatabasePool { .execute(db) try #sql(""" CREATE TABLE "parents"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()) + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) ) STRICT """) .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteRestricts"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT """) .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteSetNulls"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """) .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """) From 22c6a32174257b94c23db101c3ddba3264ac8931 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 25 Jun 2025 17:45:01 -0700 Subject: [PATCH 219/581] clean up --- Examples/CloudKitDemo/Schema.swift | 7 ------- .../SharingGRDBCore/CloudKit/CloudContainer.swift | 8 ++------ .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../SharingGRDBCore/CloudKit/CloudKitSharing.swift | 10 ++-------- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 1 - .../CloudKitTests/SyncEngineSetUpTests.swift | 14 ++++++-------- Tests/SharingGRDBTests/Internal/GRDBHelpers.swift | 3 +++ 7 files changed, 14 insertions(+), 31 deletions(-) diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index bda2337a..aa011a0a 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -54,13 +54,6 @@ func appDatabase() throws -> any DatabaseWriter { """) .execute(db) } - migrator.registerMigration("Alter table") { db in - try #sql(""" - ALTER TABLE "counters" - ADD COLUMN "newFeature" TEXT - """) - .execute(db) - } try migrator.migrate(database) return database diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index 22d4689c..41e83cc9 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -1,8 +1,7 @@ #if canImport(CloudKit) import CloudKit -// TODO: make AnyObject -package protocol CloudContainerProtocol: Equatable, Hashable, Sendable { +package protocol CloudContainerProtocol: AnyObject, Equatable, Hashable, Sendable { var sharedDatabase: any CloudDatabase { get } var privateDatabase: any CloudDatabase { get } func database(for recordID: CKRecord.ID) -> any CloudDatabase @@ -12,10 +11,7 @@ package protocol CloudContainerProtocol: Equatable, Hashable, Sendable { extension CloudContainerProtocol { package func database(for recordID: CKRecord.ID) -> any CloudDatabase { - if recordID.zoneID.ownerName != CKCurrentUserDefaultName { - print("?!?!?!") - } - return recordID.zoneID.ownerName == CKCurrentUserDefaultName + recordID.zoneID.ownerName == CKCurrentUserDefaultName ? privateDatabase : sharedDatabase } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 11630e8f..528d0123 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -71,7 +71,7 @@ extension CKShare? { @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { - func update(with row: T, userModificationDate: Date?) { + package func update(with row: T, userModificationDate: Date?) { self.userModificationDate = userModificationDate for column in T.TableColumns.allColumns { func open(_ column: some TableColumnExpression) { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 6aa3e2ed..c6501f9b 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -14,17 +14,11 @@ public struct SharedRecord: Hashable, Identifiable, Sendable { public var id: CKRecord.ID { share.recordID } public static func == (lhs: Self, rhs: Self) -> Bool { - func open(_ lhs: T) -> Bool { - lhs == (rhs.container as? T) - } - return open(lhs.container) && lhs.share == rhs.share + lhs.container === rhs.container && lhs.share == rhs.share } public func hash(into hasher: inout Hasher) { - func open(_ container: some Hashable) { - hasher.combine(container) - } - open(container) + hasher.combine(ObjectIdentifier(container)) hasher.combine(share) } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 1690a788..a50e1a66 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -236,7 +236,6 @@ public final class SyncEngine: Sendable { } } - // TODO: Fetch everything private func fetchChangesFromSchemaChange(recordTypesChanged: [RecordType]) async throws { let lastKnownServerRecords = try { try metadatabase.read { db in diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index fadd4b76..acb3b8da 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -11,10 +11,12 @@ extension BaseCloudKitTests { final class SetUpTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func schemaChange() async throws { + let personalList = RemindersList(id: UUID(1), title: "Personal") + let businessList = RemindersList(id: UUID(2), title: "Business") try database.syncWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - RemindersList(id: UUID(2), title: "Business") + personalList + businessList Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) } } @@ -27,18 +29,14 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: UUID(1)) ) - personalListRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() - personalListRecord.encryptedValues["title"] = "Personal" + personalListRecord.update(with: personalList, userModificationDate: Date()) personalListRecord.encryptedValues["position"] = 1 - personalListRecord.userModificationDate = Date() let businessListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: UUID(2)) ) - businessListRecord.encryptedValues["id"] = UUID(2).uuidString.lowercased() - businessListRecord.encryptedValues["title"] = "Business" + businessListRecord.update(with: businessList, userModificationDate: Date()) businessListRecord.encryptedValues["position"] = 2 - businessListRecord.userModificationDate = Date() _ = await privateDatabase.modifyRecords( saving: [personalListRecord, businessListRecord], deleting: [], diff --git a/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift b/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift index d50e4e9a..bf7f992c 100644 --- a/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift @@ -1,6 +1,9 @@ import GRDB extension DatabaseWriter { + // TODO: Should we put this in the main library and use it everywhere? + // OR: should we make a version of 'write async' that propagates our task locals across + // the escaping boundary? func syncWrite(_ updates: (Database) throws -> T) throws -> T { try write(updates) } From 8bc829251e8a4354ec831eaa0e1b221bb9720fed Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 25 Jun 2025 20:20:00 -0700 Subject: [PATCH 220/581] clean up --- .../CloudKit/CloudContainer.swift | 12 ++-- .../CloudKit/CloudDatabase.swift | 70 ++++++++++--------- .../CloudKit/CloudKitSharing.swift | 7 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 - .../CloudKitTests/CloudKitTests.swift | 2 - .../CloudKitTests/RecordTypeTests.swift | 4 -- .../CloudKitTests/SyncEngineSetUpTests.swift | 2 - .../CloudKitTests/TriggerTests.swift | 2 - .../Internal/BaseCloudKitTests.swift | 2 - .../Internal/CloudKitTestHelpers.swift | 11 ++- 10 files changed, 55 insertions(+), 59 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index 41e83cc9..f7ec0783 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -2,9 +2,9 @@ import CloudKit package protocol CloudContainerProtocol: AnyObject, Equatable, Hashable, Sendable { - var sharedDatabase: any CloudDatabase { get } + var rawValue: CKContainer { get } var privateDatabase: any CloudDatabase { get } - func database(for recordID: CKRecord.ID) -> any CloudDatabase + var sharedDatabase: any CloudDatabase { get } @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata } @@ -18,13 +18,17 @@ extension CloudContainerProtocol { } extension CKContainer: CloudContainerProtocol { - package var sharedDatabase: any CloudDatabase { - sharedCloudDatabase + package var rawValue: CKContainer { + self } package var privateDatabase: any CloudDatabase { privateCloudDatabase } + + package var sharedDatabase: any CloudDatabase { + sharedCloudDatabase + } @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) package func shareMetadata( diff --git a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift index fafaa80f..593ad563 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift @@ -6,7 +6,8 @@ package protocol CloudDatabase: AnyObject, Hashable, Sendable { @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func records( - for ids: [CKRecord.ID] + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? ) async throws -> [CKRecord.ID : Result] @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) @@ -21,6 +22,33 @@ package protocol CloudDatabase: AnyObject, Hashable, Sendable { ) } +extension CloudDatabase { + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID] + ) async throws -> ( + saveResults: [CKRecord.ID : Result], + deleteResults: [CKRecord.ID : Result] + ) { + try await modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete, + savePolicy: .ifServerRecordUnchanged, + atomically: true + ) + } + + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + package func records( + for ids: [CKRecord.ID] + ) async throws -> [CKRecord.ID : Result] { + try await records(for: ids, desiredKeys: nil) + } +} + +extension CKDatabase: CloudDatabase {} + final class AnyCloudDatabase: CloudDatabase { let rawValue: any CloudDatabase init(_ rawValue: any CloudDatabase) { @@ -32,7 +60,8 @@ final class AnyCloudDatabase: CloudDatabase { @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func records( - for ids: [CKRecord.ID] + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? ) async throws -> [CKRecord.ID : Result] { try await rawValue.records(for: ids) } @@ -48,11 +77,11 @@ final class AnyCloudDatabase: CloudDatabase { deleteResults: [CKRecord.ID : Result] ) { try await rawValue.modifyRecords( - saving: recordsToSave, - deleting: recordIDsToDelete, - savePolicy: savePolicy, - atomically: atomically - ) + saving: recordsToSave, + deleting: recordIDsToDelete, + savePolicy: savePolicy, + atomically: atomically + ) } static func == (lhs: AnyCloudDatabase, rhs: AnyCloudDatabase) -> Bool { @@ -63,31 +92,4 @@ final class AnyCloudDatabase: CloudDatabase { hasher.combine(ObjectIdentifier(rawValue)) } } - -extension CloudDatabase { - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) - func modifyRecords( - saving recordsToSave: [CKRecord], - deleting recordIDsToDelete: [CKRecord.ID] - ) async throws -> ( - saveResults: [CKRecord.ID : Result], - deleteResults: [CKRecord.ID : Result] - ) { - try await modifyRecords( - saving: recordsToSave, - deleting: recordIDsToDelete, - savePolicy: .ifServerRecordUnchanged, - atomically: true - ) - } -} - -extension CKDatabase: CloudDatabase { - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) - package func records( - for ids: [CKRecord.ID] - ) async throws -> [CKRecord.ID : Result] { - try await records(for: ids, desiredKeys: nil) - } -} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index c6501f9b..0811405c 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -98,10 +98,7 @@ extension SyncEngine { .execute(db) } - return SharedRecord( - container: container, - share: sharedRecord - ) + return SharedRecord(container: container, share: sharedRecord) } public func acceptShare(metadata: CKShare.Metadata) async throws { @@ -141,7 +138,7 @@ extension SyncEngine { public func makeUIViewController(context: Context) -> UICloudSharingController { let controller = UICloudSharingController( share: sharedRecord.share, - container: sharedRecord.container + container: sharedRecord.container.rawValue ) controller.delegate = context.coordinator return controller diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index a50e1a66..37dd0065 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -227,8 +227,6 @@ public final class SyncEngine: Sendable { return Task { await withErrorReporting(.sqliteDataCloudKitFailure) { - // TODO: comment this out and see if things still work - try await fetchChanges() try await fetchChangesFromSchemaChange( recordTypesChanged: recordTypesToFetch.filter { !$0.isNewTable }.map(\.0) ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 2b936c79..b2c06e8f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -145,8 +145,6 @@ extension BaseCloudKitTests { @Test func tearDownAndReSetUp() async throws { try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - privateSyncEngine.assertFetchChangesScopes([.all]) - sharedSyncEngine.assertFetchChangesScopes([.all]) try database.syncWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index ede1011a..def395eb 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -132,8 +132,6 @@ extension BaseCloudKitTests { } try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - privateSyncEngine.assertFetchChangesScopes([.all]) - sharedSyncEngine.assertFetchChangesScopes([.all]) let recordTypesAfterReSetup = try database.syncWrite { db in try RecordType.all.fetchAll(db) } @@ -154,8 +152,6 @@ extension BaseCloudKitTests { .execute(db) } try await syncEngine.setUpSyncEngine() - privateSyncEngine.assertFetchChangesScopes([.all]) - sharedSyncEngine.assertFetchChangesScopes([.all]) let recordTypesAfterMigration = try database.syncWrite { db in try RecordType.order(by: \.tableName).fetchAll(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index acb3b8da..429054f7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -60,8 +60,6 @@ extension BaseCloudKitTests { syncEngine: privateSyncEngine ) #expect(batch == nil) - privateSyncEngine.assertFetchChangesScopes([.all]) - sharedSyncEngine.assertFetchChangesScopes([.all]) let remindersLists = try database.syncRead { db in try MigratedRemindersList.order(by: \.id).fetchAll(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index f0014873..f429a402 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -469,8 +469,6 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - privateSyncEngine.assertFetchChangesScopes([.all]) - sharedSyncEngine.assertFetchChangesScopes([.all]) let triggersAfterReSetUp = try database.syncWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 8a108422..638cf0a3 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -80,8 +80,6 @@ class BaseCloudKitTests: @unchecked Sendable { RemindersListPrivate.self ] ) - privateSyncEngine.assertFetchChangesScopes([.all]) - sharedSyncEngine.assertFetchChangesScopes([.all]) } deinit { diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 15010b00..5ffd35ac 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -249,7 +249,10 @@ actor MockCloudDatabase: CloudDatabase { return record } - func records(for ids: [CKRecord.ID]) throws -> [CKRecord.ID : Result] { + func records( + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? + ) throws -> [CKRecord.ID : Result] { var results: [CKRecord.ID : Result] = [:] for id in ids { results[id] = Result { try record(for: id) } @@ -291,12 +294,16 @@ actor MockCloudDatabase: CloudDatabase { final class MockCloudContainer: CloudContainerProtocol { let privateDatabase: any CloudDatabase let sharedDatabase: any CloudDatabase - + init(privateDatabase: any CloudDatabase, sharedDatabase: any CloudDatabase) { self.privateDatabase = privateDatabase self.sharedDatabase = sharedDatabase } + var rawValue: CKContainer { + fatalError("This should never be called in tests.") + } + func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata { fatalError() } From 6110d4d9217a8e65d512ad3ecf223b5da91378a2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 25 Jun 2025 20:28:48 -0700 Subject: [PATCH 221/581] more testing --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 6 ++- .../CloudKitTests/SyncEngineSetUpTests.swift | 38 +++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 37dd0065..659e09e9 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -235,11 +235,14 @@ public final class SyncEngine: Sendable { } private func fetchChangesFromSchemaChange(recordTypesChanged: [RecordType]) async throws { + // TODO: Should we do this in batches now that we save the full 'lastKnowServerRecord'? + // TODO: Or should we denormalize zoneID into the metadata table for easy access? let lastKnownServerRecords = try { try metadatabase.read { db in try SyncMetadata .where { - $0.recordType.in(recordTypesChanged.map(\.tableName)) && $0.lastKnownServerRecord.isNot(nil) + $0.recordType.in(recordTypesChanged.map(\.tableName)) + && $0.lastKnownServerRecord.isNot(nil) } .select { SQLQueryExpression( @@ -250,7 +253,6 @@ public final class SyncEngine: Sendable { .fetchAll(db) } }() - let recordIDs = lastKnownServerRecords.map(\.recordID) let recordIDsByDatabase = Dictionary(grouping: recordIDs) { AnyCloudDatabase(container.database(for: $0)) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index 429054f7..b702842b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -13,11 +13,12 @@ extension BaseCloudKitTests { @Test func schemaChange() async throws { let personalList = RemindersList(id: UUID(1), title: "Personal") let businessList = RemindersList(id: UUID(2), title: "Business") + let reminder = Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) try database.syncWrite { db in try db.seed { personalList businessList - Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + reminder } } _ = await syncEngine._nextRecordZoneChangeBatch( @@ -37,8 +38,14 @@ extension BaseCloudKitTests { ) businessListRecord.update(with: businessList, userModificationDate: Date()) businessListRecord.encryptedValues["position"] = 2 + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: UUID(1)) + ) + reminderRecord.update(with: reminder, userModificationDate: Date()) + reminderRecord.encryptedValues["position"] = 3 _ = await privateDatabase.modifyRecords( - saving: [personalListRecord, businessListRecord], + saving: [personalListRecord, businessListRecord, reminderRecord], deleting: [], savePolicy: .ifServerRecordUnchanged, atomically: true @@ -52,6 +59,13 @@ extension BaseCloudKitTests { """ ) .execute(db) + try #sql( + """ + ALTER TABLE "reminders" + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + """ + ) + .execute(db) } try await syncEngine.setUpSyncEngine() @@ -71,13 +85,31 @@ extension BaseCloudKitTests { MigratedRemindersList(id: UUID(2), title: "Business", position: 2), ] ) + + let reminders = try database.syncRead { db in + try MigratedReminder.order(by: \.id).fetchAll(db) + } + expectNoDifference( + reminders, + [ + MigratedReminder(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), + ] + ) } } } @Table("remindersLists") -struct MigratedRemindersList: Equatable, Identifiable { +fileprivate struct MigratedRemindersList: Equatable, Identifiable { + let id: UUID + var title = "" + var position = 0 +} + +@Table("reminders") +fileprivate struct MigratedReminder: Equatable, Identifiable { let id: UUID var title = "" var position = 0 + var remindersListID: RemindersList.ID } From 92612d37b2a27f8c3d335473239576ea6bb8b10c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 26 Jun 2025 10:32:22 -0700 Subject: [PATCH 222/581] clean up --- .../CloudKit/CloudContainer.swift | 24 +++++-------- .../CloudKit/CloudDatabase.swift | 2 ++ .../CloudKit/CloudKitSharing.swift | 4 +-- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 11 ++++-- .../CloudKit/SyncEngineProtocol+Live.swift | 2 +- .../CloudKit/SyncEngineProtocol.swift | 5 +-- .../CloudKitTests/SyncEngineSetUpTests.swift | 9 +++-- .../Internal/BaseCloudKitTests.swift | 10 +++--- .../Internal/CloudKitTestHelpers.swift | 36 +++++++++---------- 9 files changed, 49 insertions(+), 54 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index f7ec0783..51688445 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -1,35 +1,29 @@ #if canImport(CloudKit) import CloudKit -package protocol CloudContainerProtocol: AnyObject, Equatable, Hashable, Sendable { +package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { + associatedtype Database: CloudDatabase + var rawValue: CKContainer { get } - var privateDatabase: any CloudDatabase { get } - var sharedDatabase: any CloudDatabase { get } + var privateCloudDatabase: Database { get } + var sharedCloudDatabase: Database { get } @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata } -extension CloudContainerProtocol { +extension CloudContainer { package func database(for recordID: CKRecord.ID) -> any CloudDatabase { recordID.zoneID.ownerName == CKCurrentUserDefaultName - ? privateDatabase - : sharedDatabase + ? privateCloudDatabase + : sharedCloudDatabase } } -extension CKContainer: CloudContainerProtocol { +extension CKContainer: CloudContainer { package var rawValue: CKContainer { self } - package var privateDatabase: any CloudDatabase { - privateCloudDatabase - } - - package var sharedDatabase: any CloudDatabase { - sharedCloudDatabase - } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) package func shareMetadata( for url: URL, diff --git a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift index 593ad563..86667b8b 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift @@ -23,6 +23,7 @@ package protocol CloudDatabase: AnyObject, Hashable, Sendable { } extension CloudDatabase { +// @_disfavoredOverload @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func modifyRecords( saving recordsToSave: [CKRecord], @@ -54,6 +55,7 @@ final class AnyCloudDatabase: CloudDatabase { init(_ rawValue: any CloudDatabase) { self.rawValue = rawValue } + func record(for recordID: CKRecord.ID) async throws -> CKRecord { try await rawValue.record(for: recordID) } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 0811405c..b6cde274 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -8,7 +8,7 @@ import SwiftUI #endif public struct SharedRecord: Hashable, Identifiable, Sendable { - let container: any CloudContainerProtocol + let container: any CloudContainer public let share: CKShare public var id: CKRecord.ID { share.recordID } @@ -87,7 +87,7 @@ extension SyncEngine { configure(sharedRecord) // TODO: We are getting an "client oplock error updating record" error in the logs when // creating new shares / editing existing shares. - _ = try await container.privateDatabase.modifyRecords( + _ = try await container.privateCloudDatabase.modifyRecords( saving: [sharedRecord, rootRecord], deleting: [] ) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 659e09e9..38f5f7aa 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -20,7 +20,7 @@ public final class SyncEngine: Sendable { let defaultSyncEngines: @Sendable (any DatabaseReader, SyncEngine) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) - let container: any CloudContainerProtocol + let container: any CloudContainer public convenience init( container: CKContainer, @@ -66,7 +66,7 @@ public final class SyncEngine: Sendable { } package convenience init( - container: any CloudContainerProtocol, + container: any CloudContainer, privateSyncEngine: any SyncEngineProtocol, sharedSyncEngine: any SyncEngineProtocol, database: any DatabaseWriter, @@ -87,7 +87,7 @@ public final class SyncEngine: Sendable { } private init( - container: any CloudContainerProtocol, + container: any CloudContainer, defaultSyncEngines: @escaping @Sendable ( any DatabaseReader, SyncEngine @@ -235,6 +235,9 @@ public final class SyncEngine: Sendable { } private func fetchChangesFromSchemaChange(recordTypesChanged: [RecordType]) async throws { + // TODO: do batches for sake of CKDatabase + // only docs we found was about modifies: https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation + // recommends limiting to <400 records and <2mb data posted // TODO: Should we do this in batches now that we save the full 'lastKnowServerRecord'? // TODO: Or should we denormalize zoneID into the metadata table for easy access? let lastKnownServerRecords = try { @@ -306,6 +309,7 @@ public final class SyncEngine: Sendable { try await syncEngines.shared?.fetchChanges() } + #if DEBUG public func deleteLocalData() async throws { try await tearDownSyncEngine() withErrorReporting(.sqliteDataCloudKitFailure) { @@ -322,6 +326,7 @@ public final class SyncEngine: Sendable { } try await setUpSyncEngine() } + #endif func didUpdate(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { let zoneID = zoneID ?? Self.defaultZone.zoneID diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift index 33e60d42..413766f8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift @@ -3,7 +3,7 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine: SyncEngineProtocol { - package var cloudDatabase: any CloudDatabase { + package var cloudDatabase: CKDatabase { database } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 27173010..8dbfb242 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -2,10 +2,11 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package protocol SyncEngineProtocol: AnyObject, Sendable { +package protocol SyncEngineProtocol: AnyObject, Sendable { associatedtype State: CKSyncEngineStateProtocol + associatedtype Database: CloudDatabase - var cloudDatabase: any CloudDatabase { get } + var database: Database { get } var scope: CKDatabase.Scope { get } var state: State { get } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index b702842b..d53bb47f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -44,7 +44,7 @@ extension BaseCloudKitTests { ) reminderRecord.update(with: reminder, userModificationDate: Date()) reminderRecord.encryptedValues["position"] = 3 - _ = await privateDatabase.modifyRecords( + _ = await privateSyncEngine.database.modifyRecords( saving: [personalListRecord, businessListRecord, reminderRecord], deleting: [], savePolicy: .ifServerRecordUnchanged, @@ -78,6 +78,9 @@ extension BaseCloudKitTests { let remindersLists = try database.syncRead { db in try MigratedRemindersList.order(by: \.id).fetchAll(db) } + let reminders = try database.syncRead { db in + try MigratedReminder.order(by: \.id).fetchAll(db) + } expectNoDifference( remindersLists, [ @@ -85,10 +88,6 @@ extension BaseCloudKitTests { MigratedRemindersList(id: UUID(2), title: "Business", position: 2), ] ) - - let reminders = try database.syncRead { db in - try MigratedReminder.order(by: \.id).fetchAll(db) - } expectNoDifference( reminders, [ diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 638cf0a3..25103f0a 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -11,8 +11,6 @@ import Testing class BaseCloudKitTests: @unchecked Sendable { let database: any DatabaseWriter private let _syncEngine: any Sendable - let sharedDatabase = MockCloudDatabase() - let privateDatabase = MockCloudDatabase() private let _privateSyncEngine: any Sendable private let _sharedSyncEngine: any Sendable @@ -45,12 +43,12 @@ class BaseCloudKitTests: @unchecked Sendable { } }() let privateSyncEngine = MockSyncEngine( - cloudDatabase: privateDatabase, + database: MockCloudDatabase(), scope: .private, state: MockSyncEngineState() ) let sharedSyncEngine = MockSyncEngine( - cloudDatabase: sharedDatabase, + database: MockCloudDatabase(), scope: .shared, state: MockSyncEngineState() ) @@ -58,8 +56,8 @@ class BaseCloudKitTests: @unchecked Sendable { _sharedSyncEngine = sharedSyncEngine _syncEngine = try await SyncEngine( container: MockCloudContainer( - privateDatabase: privateSyncEngine.cloudDatabase, - sharedDatabase: sharedSyncEngine.cloudDatabase + privateCloudDatabase: privateSyncEngine.database, + sharedCloudDatabase: sharedSyncEngine.database ), privateSyncEngine: privateSyncEngine, sharedSyncEngine: sharedSyncEngine, diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 5ffd35ac..ac17dd98 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -14,26 +14,22 @@ extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) final class MockSyncEngine: SyncEngineProtocol { - private let _cloudDatabase: MockCloudDatabase + let database: MockCloudDatabase private let _state: LockIsolated private let _fetchChangesScopes = LockIsolated>([]) private let _acceptedShareMetadata = LockIsolated>([]) let scope: CKDatabase.Scope init( - cloudDatabase: MockCloudDatabase, + database: MockCloudDatabase, scope: CKDatabase.Scope, state: MockSyncEngineState ) { - _cloudDatabase = cloudDatabase + self.database = database self.scope = scope self._state = LockIsolated(state) } - var cloudDatabase: any CloudDatabase { - _cloudDatabase - } - var state: MockSyncEngineState { _state.withValue(\.self) } @@ -67,13 +63,10 @@ final class MockSyncEngine: SyncEngineProtocol { return recordID } - for savedRecord in recordsToSave { - state.remove(pendingRecordZoneChanges: [.saveRecord(savedRecord.recordID)]) - } - for recordIDToDelete in recordIDsToDelete { - state.remove(pendingRecordZoneChanges: [.deleteRecord(recordIDToDelete)]) - } - _ = await _cloudDatabase.modifyRecords( + state.remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) }) + state.remove(pendingRecordZoneChanges: recordIDsToDelete.map { .deleteRecord($0) }) + + _ = await database.modifyRecords( saving: recordsToSave, deleting: recordIDsToDelete, savePolicy: .ifServerRecordUnchanged, @@ -272,6 +265,9 @@ actor MockCloudDatabase: CloudDatabase { for recordToSave in recordsToSave { storage[recordToSave.recordID] = recordToSave } + for recordIDToDelete in recordIDsToDelete { + storage[recordIDToDelete] = nil + } return ( saveResults: Dictionary( uniqueKeysWithValues: recordsToSave.map { ($0.recordID, .success($0)) } @@ -291,13 +287,13 @@ actor MockCloudDatabase: CloudDatabase { } } -final class MockCloudContainer: CloudContainerProtocol { - let privateDatabase: any CloudDatabase - let sharedDatabase: any CloudDatabase +final class MockCloudContainer: CloudContainer { + let privateCloudDatabase: MockCloudDatabase + let sharedCloudDatabase: MockCloudDatabase - init(privateDatabase: any CloudDatabase, sharedDatabase: any CloudDatabase) { - self.privateDatabase = privateDatabase - self.sharedDatabase = sharedDatabase + init(privateCloudDatabase: MockCloudDatabase, sharedCloudDatabase: MockCloudDatabase) { + self.privateCloudDatabase = privateCloudDatabase + self.sharedCloudDatabase = sharedCloudDatabase } var rawValue: CKContainer { From dd9cdc08c57093921b4a24be31002d6766747219 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 26 Jun 2025 11:29:28 -0700 Subject: [PATCH 223/581] wip --- Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift | 8 ++++++-- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../CloudKit/SyncEngineProtocol+Live.swift | 8 -------- Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift | 1 - Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift | 4 ++-- Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift | 5 +++++ 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift index 86667b8b..d12cc1f3 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift @@ -2,6 +2,7 @@ import CloudKit package protocol CloudDatabase: AnyObject, Hashable, Sendable { + var databaseScope: CKDatabase.Scope { get } func record(for recordID: CKRecord.ID) async throws -> CKRecord @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) @@ -23,7 +24,6 @@ package protocol CloudDatabase: AnyObject, Hashable, Sendable { } extension CloudDatabase { -// @_disfavoredOverload @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func modifyRecords( saving recordsToSave: [CKRecord], @@ -55,7 +55,11 @@ final class AnyCloudDatabase: CloudDatabase { init(_ rawValue: any CloudDatabase) { self.rawValue = rawValue } - + + var databaseScope: CKDatabase.Scope { + rawValue.databaseScope + } + func record(for recordID: CKRecord.ID) async throws -> CKRecord { try await rawValue.record(for: recordID) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 38f5f7aa..cda04215 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -496,7 +496,7 @@ extension SyncEngine: CKSyncEngineDelegate { .joined(separator: ", ") logger.debug( """ - [\(syncEngine.scope.label)] nextRecordZoneChangeBatch: \(context.reason) + [\(syncEngine.database.databaseScope.label)] nextRecordZoneChangeBatch: \(context.reason) \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift index 413766f8..434b2858 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift @@ -3,14 +3,6 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine: SyncEngineProtocol { - package var cloudDatabase: CKDatabase { - database - } - - package var scope: CKDatabase.Scope { - database.databaseScope - } - package func acceptShare(metadata: ShareMetadata) async throws { guard let metadata = metadata.rawValue else { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 8dbfb242..510beff5 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -7,7 +7,6 @@ package protocol SyncEngineProtocol: AnyObject, Sendable { associatedtype Database: CloudDatabase var database: Database { get } - var scope: CKDatabase.Scope { get } var state: State { get } func acceptShare(metadata: ShareMetadata) async throws diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 25103f0a..d81385d4 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -43,12 +43,12 @@ class BaseCloudKitTests: @unchecked Sendable { } }() let privateSyncEngine = MockSyncEngine( - database: MockCloudDatabase(), + database: MockCloudDatabase(databaseScope: .private), scope: .private, state: MockSyncEngineState() ) let sharedSyncEngine = MockSyncEngine( - database: MockCloudDatabase(), + database: MockCloudDatabase(databaseScope: .shared), scope: .shared, state: MockSyncEngineState() ) diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index ac17dd98..ed084d8e 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -233,9 +233,14 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl actor MockCloudDatabase: CloudDatabase { var storage: [CKRecord.ID: CKRecord] = [:] + let databaseScope: CKDatabase.Scope struct RecordNotFound: Error {} + init(databaseScope: CKDatabase.Scope) { + self.databaseScope = databaseScope + } + func record(for recordID: CKRecord.ID) throws -> CKRecord { guard let record = storage[recordID] else { throw RecordNotFound() } From ed66f5b441d1e4e477013c26c834dd1165b1cc1b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 26 Jun 2025 11:30:45 -0700 Subject: [PATCH 224/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 -------- Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift | 7 ------- 2 files changed, 15 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index cda04215..761b731c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -301,14 +301,6 @@ public final class SyncEngine: Sendable { _ = await (privateCancellation, sharedCancellation) } - // TODO: resendAll() ? - - public func fetchChanges() async throws { - let syncEngines = syncEngines.withValue(\.self) - try await syncEngines.private?.fetchChanges() - try await syncEngines.shared?.fetchChanges() - } - #if DEBUG public func deleteLocalData() async throws { try await tearDownSyncEngine() diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 510beff5..f214f7aa 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -18,13 +18,6 @@ package protocol SyncEngineProtocol: AnyObject, Sendable { ) async -> CKSyncEngine.RecordZoneChangeBatch? } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngineProtocol { - package func fetchChanges() async throws { - try await fetchChanges(CKSyncEngine.FetchChangesOptions()) - } -} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package struct ShareMetadata: Hashable { package var containerIdentifier: String From 13f0f80954b29250a1b440d75a3b231aa2f884a2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 26 Jun 2025 11:31:13 -0700 Subject: [PATCH 225/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift | 1 - Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift | 4 ---- 2 files changed, 5 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index f214f7aa..1e39f3ef 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -11,7 +11,6 @@ package protocol SyncEngineProtocol: AnyObject, Sendable { func acceptShare(metadata: ShareMetadata) async throws func cancelOperations() async - func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws func recordZoneChangeBatch( pendingChanges: [CKSyncEngine.PendingRecordZoneChange], recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index ed084d8e..b11fa165 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -34,10 +34,6 @@ final class MockSyncEngine: SyncEngineProtocol { _state.withValue(\.self) } - func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { - _ = _fetchChangesScopes.withValue { $0.insert(options.scope) } - } - func acceptShare(metadata: ShareMetadata) { _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } } From 5334ba03c8c52532c28c2af591904646ae3b9272 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 26 Jun 2025 12:50:54 -0700 Subject: [PATCH 226/581] Propagate task locals across escaping closure. --- .../CloudKit/CloudKitSharing.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2057 +++++++++-------- .../Internal/GRDBHelpers.swift | 23 + .../CloudKitTests/CloudKitTests.swift | 24 +- .../CloudKitTests/NewTableSyncTests.swift | 2 +- .../NextRecordZoneChangeBatchTests.swift | 10 +- .../CloudKitTests/RecordTypeTests.swift | 14 +- .../CloudKitTests/SharingTests.swift | 6 +- .../CloudKitTests/SyncEngineSetUpTests.swift | 8 +- .../CloudKitTests/TriggerTests.swift | 6 +- Tests/SharingGRDBTests/FetchAllTests.swift | 6 +- Tests/SharingGRDBTests/FetchOneTests.swift | 20 +- Tests/SharingGRDBTests/FetchTests.swift | 12 +- Tests/SharingGRDBTests/IntegrationTests.swift | 12 +- .../Internal/GRDBHelpers.swift | 16 - 15 files changed, 1113 insertions(+), 1105 deletions(-) create mode 100644 Sources/SharingGRDBCore/Internal/GRDBHelpers.swift delete mode 100644 Tests/SharingGRDBTests/Internal/GRDBHelpers.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index b6cde274..d648c0d4 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -91,7 +91,7 @@ extension SyncEngine { saving: [sharedRecord, rootRecord], deleting: [] ) - try await database.write { db in + try await database.asyncWrite { db in try SyncMetadata .find(recordName) .update { $0.share = sharedRecord } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 761b731c..66084299 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1,857 +1,951 @@ #if canImport(CloudKit) -import CloudKit -import ConcurrencyExtras -import OSLog - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -public final class SyncEngine: Sendable { - public static nonisolated let defaultZone = CKRecordZone( - zoneName: "co.pointfree.SQLiteData.defaultZone" - ) - - let database: any DatabaseWriter - let logger: Logger - let metadatabase: any DatabaseReader - let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - let privateTables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] - let foreignKeysByTableName: [String: [ForeignKey]] - let syncEngines = LockIsolated(SyncEngines()) - let defaultSyncEngines: - @Sendable (any DatabaseReader, SyncEngine) - -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) - let container: any CloudContainer - - public convenience init( - container: CKContainer, - database: any DatabaseWriter, - logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] - ) throws { - try self.init( - container: container, - defaultSyncEngines: { database, syncEngine in - ( - private: CKSyncEngine( - CKSyncEngine.Configuration( - database: container.privateCloudDatabase, - stateSerialization: try? database.read { db in // TODO: write test for this - try StateSerialization.find(CKDatabase.Scope.private).select(\.data).fetchOne(db) - }, - delegate: syncEngine - ) - ), - shared: CKSyncEngine( - CKSyncEngine.Configuration( - database: container.sharedCloudDatabase, - stateSerialization: try? database.read { db in // TODO: write test for this - try StateSerialization.find(CKDatabase.Scope.shared).select(\.data).fetchOne(db) - }, - delegate: syncEngine - ) - ) - ) - }, - database: database, - logger: logger, - metadatabaseURL: URL.metadatabase(containerIdentifier: container.containerIdentifier), - tables: tables, - privateTables: privateTables - ) - _ = try setUpSyncEngine( - database: database, - metadatabase: metadatabase - ) - } + import CloudKit + import ConcurrencyExtras + import OSLog - package convenience init( - container: any CloudContainer, - privateSyncEngine: any SyncEngineProtocol, - sharedSyncEngine: any SyncEngineProtocol, - database: any DatabaseWriter, - metadatabaseURL: URL, - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] - ) async throws { - try self.init( - container: container, - defaultSyncEngines: { _, _ in (privateSyncEngine, sharedSyncEngine) }, - database: database, - logger: Logger(.disabled), - metadatabaseURL: metadatabaseURL, - tables: tables, - privateTables: privateTables + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public final class SyncEngine: Sendable { + public static nonisolated let defaultZone = CKRecordZone( + zoneName: "co.pointfree.SQLiteData.defaultZone" ) - try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value - } - private init( - container: any CloudContainer, - defaultSyncEngines: @escaping @Sendable ( - any DatabaseReader, - SyncEngine - ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), - database: any DatabaseWriter, - logger: Logger, - metadatabaseURL: URL, - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] - ) throws { - try validateSchema(tables: tables, database: database) - // TODO: Explain why / link to documentation? - precondition( - !database.configuration.foreignKeysEnabled, - """ - Foreign key support must be disabled to synchronize with CloudKit. - """ - ) - self.container = container - self.defaultSyncEngines = defaultSyncEngines - self.database = database - self.logger = logger - self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) - self.tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)).map(\.type) - self.privateTables = privateTables - self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) - self.foreignKeysByTableName = Dictionary( - uniqueKeysWithValues: try database.read { db in - try tables.map { table -> (String, [ForeignKey]) in + // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates + @TaskLocal static var isUpdatingWithServerRecord = false + + let database: any DatabaseWriter + let logger: Logger + let metadatabase: any DatabaseReader + let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + let privateTables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + let foreignKeysByTableName: [String: [ForeignKey]] + let syncEngines = LockIsolated(SyncEngines()) + let defaultSyncEngines: + @Sendable (any DatabaseReader, SyncEngine) + -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) + let container: any CloudContainer + + public convenience init( + container: CKContainer, + database: any DatabaseWriter, + logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] + ) throws { + try self.init( + container: container, + defaultSyncEngines: { database, syncEngine in ( - table.tableName, - try ForeignKey.all(table).fetchAll(db) + private: CKSyncEngine( + CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: try? database.read { db in // TODO: write test for this + try StateSerialization.find(CKDatabase.Scope.private).select(\.data).fetchOne(db) + }, + delegate: syncEngine + ) + ), + shared: CKSyncEngine( + CKSyncEngine.Configuration( + database: container.sharedCloudDatabase, + stateSerialization: try? database.read { db in // TODO: write test for this + try StateSerialization.find(CKDatabase.Scope.shared).select(\.data).fetchOne(db) + }, + delegate: syncEngine + ) + ) ) - } - } - ) - } - - package func setUpSyncEngine() async throws { - try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value - } - - nonisolated func setUpSyncEngine( - database: any DatabaseWriter, - metadatabase: any DatabaseReader - ) throws -> Task? { - try database.write { db in - let hasAttachedMetadatabase: Bool = - try SQLQueryExpression( - """ - SELECT count(*) - FROM pragma_database_list - WHERE "name" = \(bind: String.sqliteDataCloudKitSchemaName) - """, - as: Int.self + }, + database: database, + logger: logger, + metadatabaseURL: URL.metadatabase(containerIdentifier: container.containerIdentifier), + tables: tables, + privateTables: privateTables + ) + _ = try setUpSyncEngine( + database: database, + metadatabase: metadatabase ) - .fetchOne(db) == 1 - if !hasAttachedMetadatabase { - try SQLQueryExpression( - """ - ATTACH DATABASE \(bind: metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) - """ - ) - .execute(db) - } - db.add(function: .datetime) - db.add(function: .isUpdatingWithServerRecord) - db.add(function: .didUpdate(syncEngine: self)) - db.add(function: .didDelete(syncEngine: self)) - - for trigger in SyncMetadata.callbackTriggers { - try trigger.execute(db) - } - - for table in tables { - try table.createTriggers( - foreignKeysByTableName: foreignKeysByTableName, - tablesByName: tablesByName, - db: db - ) - } } - let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) - syncEngines.withValue { - $0 = SyncEngines( - private: privateSyncEngine, - shared: sharedSyncEngine + package convenience init( + container: any CloudContainer, + privateSyncEngine: any SyncEngineProtocol, + sharedSyncEngine: any SyncEngineProtocol, + database: any DatabaseWriter, + metadatabaseURL: URL, + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] + ) async throws { + try self.init( + container: container, + defaultSyncEngines: { _, _ in (privateSyncEngine, sharedSyncEngine) }, + database: database, + logger: Logger(.disabled), + metadatabaseURL: metadatabaseURL, + tables: tables, + privateTables: privateTables ) + try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value } - let previousRecordTypes = try metadatabase.read { db in - try RecordType.all.fetchAll(db) - } - let currentRecordTypes = try database.read { db in - try SQLQueryExpression( + + private init( + container: any CloudContainer, + defaultSyncEngines: @escaping @Sendable ( + any DatabaseReader, + SyncEngine + ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), + database: any DatabaseWriter, + logger: Logger, + metadatabaseURL: URL, + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] + ) throws { + try validateSchema(tables: tables, database: database) + // TODO: Explain why / link to documentation? + precondition( + !database.configuration.foreignKeysEnabled, + """ + Foreign key support must be disabled to synchronize with CloudKit. """ - SELECT "name", "sql" - FROM "sqlite_master" - WHERE "type" = 'table' - AND "name" IN (\(tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) - """, - as: RecordType.self ) - .fetchAll(db) - } - let recordTypesToFetch = currentRecordTypes.compactMap { currentRecordType in - guard - let existingRecordType = previousRecordTypes.first(where: { previousRecordType in - currentRecordType.tableName == previousRecordType.tableName - }) - else { return (currentRecordType, isNewTable: true) } - return existingRecordType.schema == currentRecordType.schema - ? nil - : (currentRecordType, isNewTable: false) + self.container = container + self.defaultSyncEngines = defaultSyncEngines + self.database = database + self.logger = logger + self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) + self.tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)).map( + \.type + ) + self.privateTables = privateTables + self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) + self.foreignKeysByTableName = Dictionary( + uniqueKeysWithValues: try database.read { db in + try tables.map { table -> (String, [ForeignKey]) in + ( + table.tableName, + try ForeignKey.all(table).fetchAll(db) + ) + } + } + ) } - guard !recordTypesToFetch.isEmpty - else { return nil } + package func setUpSyncEngine() async throws { + try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value + } - withErrorReporting(.sqliteDataCloudKitFailure) { + nonisolated func setUpSyncEngine( + database: any DatabaseWriter, + metadatabase: any DatabaseReader + ) throws -> Task? { try database.write { db in - for (recordType, isNewTable) in recordTypesToFetch { - try RecordType - .upsert { RecordType.Draft(recordType) } - .execute(db) - if isNewTable, let table = tablesByName[recordType.tableName] { - func open>(_: T.Type) throws { - try T - .update { $0.primaryKey = $0.primaryKey } - .execute(db) - } - try open(table) - } + let hasAttachedMetadatabase: Bool = + try SQLQueryExpression( + """ + SELECT count(*) + FROM pragma_database_list + WHERE "name" = \(bind: String.sqliteDataCloudKitSchemaName) + """, + as: Int.self + ) + .fetchOne(db) == 1 + if !hasAttachedMetadatabase { + try SQLQueryExpression( + """ + ATTACH DATABASE \(bind: metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) + """ + ) + .execute(db) + } + db.add(function: .datetime) + db.add(function: .isUpdatingWithServerRecord) + db.add(function: .didUpdate(syncEngine: self)) + db.add(function: .didDelete(syncEngine: self)) + + for trigger in SyncMetadata.callbackTriggers { + try trigger.execute(db) + } + + for table in tables { + try table.createTriggers( + foreignKeysByTableName: foreignKeysByTableName, + tablesByName: tablesByName, + db: db + ) } } - } - return Task { - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await fetchChangesFromSchemaChange( - recordTypesChanged: recordTypesToFetch.filter { !$0.isNewTable }.map(\.0) + let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) + syncEngines.withValue { + $0 = SyncEngines( + private: privateSyncEngine, + shared: sharedSyncEngine ) } - } - } + let previousRecordTypes = try metadatabase.read { db in + try RecordType.all.fetchAll(db) + } + let currentRecordTypes = try database.read { db in + try SQLQueryExpression( + """ + SELECT "name", "sql" + FROM "sqlite_master" + WHERE "type" = 'table' + AND "name" IN (\(tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) + """, + as: RecordType.self + ) + .fetchAll(db) + } + let recordTypesToFetch = currentRecordTypes.compactMap { currentRecordType in + guard + let existingRecordType = previousRecordTypes.first(where: { previousRecordType in + currentRecordType.tableName == previousRecordType.tableName + }) + else { return (currentRecordType, isNewTable: true) } + return existingRecordType.schema == currentRecordType.schema + ? nil + : (currentRecordType, isNewTable: false) + } - private func fetchChangesFromSchemaChange(recordTypesChanged: [RecordType]) async throws { - // TODO: do batches for sake of CKDatabase - // only docs we found was about modifies: https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation - // recommends limiting to <400 records and <2mb data posted - // TODO: Should we do this in batches now that we save the full 'lastKnowServerRecord'? - // TODO: Or should we denormalize zoneID into the metadata table for easy access? - let lastKnownServerRecords = try { - try metadatabase.read { db in - try SyncMetadata - .where { - $0.recordType.in(recordTypesChanged.map(\.tableName)) - && $0.lastKnownServerRecord.isNot(nil) - } - .select { - SQLQueryExpression( - "\($0.lastKnownServerRecord)", - as: CKRecord.DataRepresentation.self - ) + guard !recordTypesToFetch.isEmpty + else { return nil } + + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + for (recordType, isNewTable) in recordTypesToFetch { + try RecordType + .upsert { RecordType.Draft(recordType) } + .execute(db) + if isNewTable, let table = tablesByName[recordType.tableName] { + func open>(_: T.Type) throws { + try T + .update { $0.primaryKey = $0.primaryKey } + .execute(db) + } + try open(table) + } } - .fetchAll(db) + } + } + + return Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await fetchChangesFromSchemaChange( + recordTypesChanged: recordTypesToFetch.filter { !$0.isNewTable }.map(\.0) + ) + } } - }() - let recordIDs = lastKnownServerRecords.map(\.recordID) - let recordIDsByDatabase = Dictionary(grouping: recordIDs) { - AnyCloudDatabase(container.database(for: $0)) } - for (database, recordIDs) in recordIDsByDatabase { - let results = try await database.records(for: recordIDs) - for (_, result) in results { - switch result { - case .success(let record): - upsertFromServerRecord(record) - break - case .failure(let error): - reportIssue(error) - break + + private func fetchChangesFromSchemaChange(recordTypesChanged: [RecordType]) async throws { + // TODO: do batches for sake of CKDatabase + // only docs we found was about modifies: https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation + // recommends limiting to <400 records and <2mb data posted + // TODO: Should we do this in batches now that we save the full 'lastKnowServerRecord'? + // TODO: Or should we denormalize zoneID into the metadata table for easy access? + let lastKnownServerRecords = try await metadatabase.read { db in + try SyncMetadata + .where { + $0.recordType.in(recordTypesChanged.map(\.tableName)) + && $0.lastKnownServerRecord.isNot(nil) + } + .select { + SQLQueryExpression( + "\($0.lastKnownServerRecord)", + as: CKRecord.DataRepresentation.self + ) + } + .fetchAll(db) + } + let recordIDs = lastKnownServerRecords.map(\.recordID) + let recordIDsByDatabase = Dictionary(grouping: recordIDs) { + AnyCloudDatabase(container.database(for: $0)) + } + for (database, recordIDs) in recordIDsByDatabase { + let results = try await database.records(for: recordIDs) + for (_, result) in results { + switch result { + case .success(let record): + upsertFromServerRecord(record) + break + case .failure(let error): + reportIssue(error) + break + } } } } - } - package func tearDownSyncEngine() async throws { - let syncEngines = syncEngines.withValue(\.self) - async let privateCancellation: Void? = syncEngines.private?.cancelOperations() - async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() + package func tearDownSyncEngine() async throws { + let syncEngines = syncEngines.withValue(\.self) + async let privateCancellation: Void? = syncEngines.private?.cancelOperations() + async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() - try await database.write { db in - for table in self.tables { - try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) + try await database.asyncWrite { db in + for table in self.tables { + try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) + } + for trigger in SyncMetadata.callbackTriggers.reversed() { + try trigger.drop().execute(db) + } + db.remove(function: .didDelete(syncEngine: self)) + db.remove(function: .didUpdate(syncEngine: self)) + db.remove(function: .isUpdatingWithServerRecord) + db.remove(function: .datetime) } - for trigger in SyncMetadata.callbackTriggers.reversed() { - try trigger.drop().execute(db) + try await database.asyncWrite { db in + // TODO: Do an `.erase()` + re-migrate + try SyncMetadata.delete().execute(db) + try RecordType.delete().execute(db) + try StateSerialization.delete().execute(db) } - db.remove(function: .didDelete(syncEngine: self)) - db.remove(function: .didUpdate(syncEngine: self)) - db.remove(function: .isUpdatingWithServerRecord) - db.remove(function: .datetime) + _ = await (privateCancellation, sharedCancellation) } - try await database.write { db in - // TODO: Do an `.erase()` + re-migrate - try SyncMetadata.delete().execute(db) - try RecordType.delete().execute(db) - try StateSerialization.delete().execute(db) - } - _ = await (privateCancellation, sharedCancellation) - } - #if DEBUG - public func deleteLocalData() async throws { - try await tearDownSyncEngine() - withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in - for table in tables { - func open(_: T.Type) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try T.delete().execute(db) + #if DEBUG + public func deleteLocalData() async throws { + try await tearDownSyncEngine() + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + for table in tables { + func open(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try T.delete().execute(db) + } + } + open(table) } } - open(table) } + try await setUpSyncEngine() } - } - try await setUpSyncEngine() - } - #endif + #endif - func didUpdate(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { - let zoneID = zoneID ?? Self.defaultZone.zoneID - let syncEngine = self.syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared - } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: recordName.rawValue, - zoneID: zoneID + func didUpdate(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { + let zoneID = zoneID ?? Self.defaultZone.zoneID + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + syncEngine?.state.add( + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: recordName.rawValue, + zoneID: zoneID + ) ) - ) - ] - ) - } - - func didDelete(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { - let zoneID = zoneID ?? Self.defaultZone.zoneID - let syncEngine = self.syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + ] + ) } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .deleteRecord( - CKRecord.ID( - recordName: recordName.rawValue, - zoneID: zoneID + + func didDelete(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { + let zoneID = zoneID ?? Self.defaultZone.zoneID + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + syncEngine?.state.add( + pendingRecordZoneChanges: [ + .deleteRecord( + CKRecord.ID( + recordName: recordName.rawValue, + zoneID: zoneID + ) ) - ) - ] - ) + ] + ) + } } -} -extension PrimaryKeyedTable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func createTriggers( - foreignKeysByTableName: [String: [ForeignKey]], - tablesByName: [String: any PrimaryKeyedTable.Type], - db: Database - ) throws { - let parentForeignKey = - foreignKeysByTableName[tableName]?.count == 1 - ? foreignKeysByTableName[tableName]?.first - : nil + extension PrimaryKeyedTable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func createTriggers( + foreignKeysByTableName: [String: [ForeignKey]], + tablesByName: [String: any PrimaryKeyedTable.Type], + db: Database + ) throws { + let parentForeignKey = + foreignKeysByTableName[tableName]?.count == 1 + ? foreignKeysByTableName[tableName]?.first + : nil + + for trigger in metadataTriggers(parentForeignKey: parentForeignKey) { + try trigger.execute(db) + } - for trigger in metadataTriggers(parentForeignKey: parentForeignKey) { - try trigger.execute(db) + let foreignKeys = foreignKeysByTableName[tableName] ?? [] + for foreignKey in foreignKeys { + guard let parent = tablesByName[foreignKey.table] else { + reportIssue("TODO") + continue + } + try foreignKey.createTriggers(Self.self, belongsTo: parent, db: db) + } } - let foreignKeys = foreignKeysByTableName[tableName] ?? [] - for foreignKey in foreignKeys { - guard let parent = tablesByName[foreignKey.table] else { - reportIssue("TODO") - continue + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func dropTriggers( + foreignKeysByTableName: [String: [ForeignKey]], + db: Database + ) throws { + let foreignKeys = foreignKeysByTableName[tableName] ?? [] + for foreignKey in foreignKeys.reversed() { + try foreignKey.dropTriggers(for: Self.self, db: db) + } + + for trigger in metadataTriggers(parentForeignKey: nil).reversed() { + try trigger.drop().execute(db) } - try foreignKey.createTriggers(Self.self, belongsTo: parent, db: db) } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func dropTriggers( - foreignKeysByTableName: [String: [ForeignKey]], - db: Database - ) throws { - let foreignKeys = foreignKeysByTableName[tableName] ?? [] - for foreignKey in foreignKeys.reversed() { - try foreignKey.dropTriggers(for: Self.self, db: db) + extension SyncEngine: CKSyncEngineDelegate { + public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + logger.log(event, syncEngine: syncEngine) + + switch event { + case .accountChange(let event): + await handleAccountChange(event) + case .stateUpdate(let event): + handleStateUpdate(event, syncEngine: syncEngine) + case .fetchedDatabaseChanges(let event): + handleFetchedDatabaseChanges(event) + case .sentDatabaseChanges: + break + case .fetchedRecordZoneChanges(let event): + await handleFetchedRecordZoneChanges( + modifications: event.modifications.map(\.record), + deletions: event.deletions.map { ($0.recordID, $0.recordType) } + ) + case .sentRecordZoneChanges(let event): + handleSentRecordZoneChanges(event, syncEngine: syncEngine) + case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, + .didFetchChanges, .willSendChanges, .didSendChanges: + break + @unknown default: + break + } } - for trigger in metadataTriggers(parentForeignKey: nil).reversed() { - try trigger.drop().execute(db) - } - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine: CKSyncEngineDelegate { - public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - logger.log(event, syncEngine: syncEngine) - - switch event { - case .accountChange(let event): - await handleAccountChange(event) - case .stateUpdate(let event): - handleStateUpdate(event, syncEngine: syncEngine) - case .fetchedDatabaseChanges(let event): - handleFetchedDatabaseChanges(event) - case .sentDatabaseChanges: - break - case .fetchedRecordZoneChanges(let event): - await handleFetchedRecordZoneChanges( - modifications: event.modifications.map(\.record), - deletions: event.deletions.map { ($0.recordID, $0.recordType) } + public func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + await _nextRecordZoneChangeBatch( + SendChangesContext(context: context), + syncEngine: syncEngine ) - case .sentRecordZoneChanges(let event): - handleSentRecordZoneChanges(event, syncEngine: syncEngine) - case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, - .didFetchChanges, .willSendChanges, .didSendChanges: - break - @unknown default: - break } - } - public func nextRecordZoneChangeBatch( - _ context: CKSyncEngine.SendChangesContext, - syncEngine: CKSyncEngine - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - await _nextRecordZoneChangeBatch( - SendChangesContext(context: context), - syncEngine: syncEngine - ) - } - - package func _nextRecordZoneChangeBatch( - _ context: SendChangesContext, - syncEngine: any SyncEngineProtocol - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let allChanges = syncEngine.state.pendingRecordZoneChanges.filter( - context.options.scope.contains - ) - guard !allChanges.isEmpty - else { return nil } - - var allChangesByIsDeleted = Dictionary(grouping: allChanges) { - switch $0 { - case .deleteRecord: true - case .saveRecord: false - @unknown default: false + package func _nextRecordZoneChangeBatch( + _ context: SendChangesContext, + syncEngine: any SyncEngineProtocol + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + let allChanges = syncEngine.state.pendingRecordZoneChanges.filter( + context.options.scope.contains + ) + guard !allChanges.isEmpty + else { return nil } + + var allChangesByIsDeleted = Dictionary(grouping: allChanges) { + switch $0 { + case .deleteRecord: true + case .saveRecord: false + @unknown default: false + } } - } - // TODO: why did we do this again? can we test it? - allChangesByIsDeleted[true]?.reverse() - let changes = allChangesByIsDeleted.reduce(into: []) { changes, keyValue in - changes += keyValue.value - } - - #if DEBUG - struct State { - var missingTables: [CKRecord.ID] = [] - var missingRecords: [CKRecord.ID] = [] - var sentRecords: [CKRecord.ID] = [] + // TODO: why did we do this again? can we test it? + allChangesByIsDeleted[true]?.reverse() + let changes = allChangesByIsDeleted.reduce(into: []) { changes, keyValue in + changes += keyValue.value } - let state = LockIsolated(State()) - defer { - let state = state.withValue(\.self) - let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - logger.debug( - """ - [\(syncEngine.database.databaseScope.label)] nextRecordZoneChangeBatch: \(context.reason) - \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") - \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") - \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") - """ - ) - } - #endif - let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in #if DEBUG - var missingTable: CKRecord.ID? - var missingRecord: CKRecord.ID? - var sentRecord: CKRecord.ID? + struct State { + var missingTables: [CKRecord.ID] = [] + var missingRecords: [CKRecord.ID] = [] + var sentRecords: [CKRecord.ID] = [] + } + let state = LockIsolated(State()) defer { - state.withValue { [missingTable, missingRecord, sentRecord] in - if let missingTable { $0.missingTables.append(missingTable) } - if let missingRecord { $0.missingRecords.append(missingRecord) } - if let sentRecord { $0.sentRecords.append(sentRecord) } - } + let state = state.withValue(\.self) + let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + logger.debug( + """ + [\(syncEngine.database.databaseScope.label)] nextRecordZoneChangeBatch: \(context.reason) + \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") + \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") + \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") + """ + ) } #endif - guard - let recordName = SyncMetadata.RecordName(recordID: recordID), - let metadata = metadataFor(recordName: recordName) - else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) - return nil - } - guard let table = tablesByName[metadata.recordType] - else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) - missingTable = recordID - return nil - } - func open>(_: T.Type) async -> CKRecord? { - let row = - withErrorReporting { - try database.read { db in - try T.find(recordName.id).fetchOne(db) + let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in + #if DEBUG + var missingTable: CKRecord.ID? + var missingRecord: CKRecord.ID? + var sentRecord: CKRecord.ID? + defer { + state.withValue { [missingTable, missingRecord, sentRecord] in + if let missingTable { $0.missingTables.append(missingTable) } + if let missingRecord { $0.missingRecords.append(missingRecord) } + if let sentRecord { $0.sentRecords.append(sentRecord) } } } - ?? nil - guard let row + #endif + + guard + let recordName = SyncMetadata.RecordName(recordID: recordID), + let metadata = metadataFor(recordName: recordName) else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) - missingRecord = recordID return nil } + guard let table = tablesByName[metadata.recordType] + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingTable = recordID + return nil + } + func open>(_: T.Type) async -> CKRecord? { + let row = + withErrorReporting { + try database.read { db in + try T.find(recordName.id).fetchOne(db) + } + } + ?? nil + guard let row + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingRecord = recordID + return nil + } - let record = - metadata.lastKnownServerRecord - ?? CKRecord( - recordType: metadata.recordType, - recordID: recordID - ) - record.parent = metadata.parentRecordName.flatMap { parentRecordName in - guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) - else { return nil } - return CKRecord.Reference( - recordID: CKRecord.ID( - recordName: parentRecordName.rawValue, - zoneID: record.recordID.zoneID - ), - action: .none + let record = + metadata.lastKnownServerRecord + ?? CKRecord( + recordType: metadata.recordType, + recordID: recordID + ) + record.parent = metadata.parentRecordName.flatMap { parentRecordName in + guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) + else { return nil } + return CKRecord.Reference( + recordID: CKRecord.ID( + recordName: parentRecordName.rawValue, + zoneID: record.recordID.zoneID + ), + action: .none + ) + } + record.update( + with: T(queryOutput: row), + userModificationDate: metadata.userModificationDate ) + refreshLastKnownServerRecord(record) + sentRecord = recordID + return record } - record.update( - with: T(queryOutput: row), - userModificationDate: metadata.userModificationDate - ) - refreshLastKnownServerRecord(record) - sentRecord = recordID - return record + return await open(table) } - return await open(table) + return batch } - return batch - } - private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) async { - switch event.changeType { - case .signIn: - syncEngines.withValue { - $0.private?.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) - } - for table in tables { - withErrorReporting(.sqliteDataCloudKitFailure) { - let recordNames = try database.read { db in - func open>(_: T.Type) throws -> [SyncMetadata.RecordName] { - try T - .select(\.primaryKey) - .fetchAll(db) - .map { T.recordName(for: $0) } + private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) async { + switch event.changeType { + case .signIn: + syncEngines.withValue { + $0.private?.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) + } + for table in tables { + withErrorReporting(.sqliteDataCloudKitFailure) { + let recordNames = try database.read { db in + func open>(_: T.Type) throws -> [SyncMetadata.RecordName] { + try T + .select(\.primaryKey) + .fetchAll(db) + .map { T.recordName(for: $0) } + } + return try open(table) } - return try open(table) - } - syncEngines.withValue { - $0.private?.state.add( - pendingRecordZoneChanges: recordNames.map { - .saveRecord( - CKRecord.ID( - recordName: $0.rawValue, - zoneID: Self.defaultZone.zoneID + syncEngines.withValue { + $0.private?.state.add( + pendingRecordZoneChanges: recordNames.map { + .saveRecord( + CKRecord.ID( + recordName: $0.rawValue, + zoneID: Self.defaultZone.zoneID + ) ) - ) - } - ) + } + ) + } } } - } - case .signOut, .switchAccounts: - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await deleteLocalData() - } - @unknown default: - break - } - } - - private func handleStateUpdate( - _ event: CKSyncEngine.Event.StateUpdate, - syncEngine: CKSyncEngine - ) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in - try StateSerialization.upsert { - StateSerialization.Draft( - scope: syncEngine.database.databaseScope, - data: event.stateSerialization - ) + case .signOut, .switchAccounts: + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await deleteLocalData() } - .execute(db) + @unknown default: + break } } - } - private func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { - // TODO: How to handle this? - $isUpdatingWithServerRecord.withValue(true) { + private func handleStateUpdate( + _ event: CKSyncEngine.Event.StateUpdate, + syncEngine: CKSyncEngine + ) { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in - for deletion in event.deletions { - // if let table = tablesByName[deletion.zoneID.zoneName] { - // func open(_: T.Type) { - // withErrorReporting(.sqliteDataCloudKitFailure) { - // try T.delete().execute(db) - // } - // } - // open(table) + try StateSerialization.upsert { + StateSerialization.Draft( + scope: syncEngine.database.databaseScope, + data: event.stateSerialization + ) } + .execute(db) } - - // TODO: Deal with modifications? - _ = event.modifications } } - } - package func handleFetchedRecordZoneChanges( - modifications: [CKRecord], - deletions: [(CKRecord.ID, CKRecord.RecordType)] - ) async { - await $isUpdatingWithServerRecord.withValue(true) { - for record in modifications { - if let share = record as? CKShare { - await withErrorReporting { - try await cacheShare(share) + private func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { + // TODO: How to handle this? + Self.$isUpdatingWithServerRecord.withValue(true) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + for deletion in event.deletions { + // if let table = tablesByName[deletion.zoneID.zoneName] { + // func open(_: T.Type) { + // withErrorReporting(.sqliteDataCloudKitFailure) { + // try T.delete().execute(db) + // } + // } + // open(table) + } } - } else { - upsertFromServerRecord(record) - refreshLastKnownServerRecord(record) + + // TODO: Deal with modifications? + _ = event.modifications } - if let shareReference = record.share, - let shareRecord = try? await container.database(for: shareReference.recordID) - .record(for: shareReference.recordID), - let share = shareRecord as? CKShare - { - await withErrorReporting { - try await cacheShare(share) + } + } + + package func handleFetchedRecordZoneChanges( + modifications: [CKRecord], + deletions: [(CKRecord.ID, CKRecord.RecordType)] + ) async { + await Self.$isUpdatingWithServerRecord.withValue(true) { + for record in modifications { + if let share = record as? CKShare { + await withErrorReporting { + try await cacheShare(share) + } + } else { + upsertFromServerRecord(record) + refreshLastKnownServerRecord(record) + } + if let shareReference = record.share, + let shareRecord = try? await container.database(for: shareReference.recordID) + .record(for: shareReference.recordID), + let share = shareRecord as? CKShare + { + await withErrorReporting { + try await cacheShare(share) + } } } - } - for (recordID, recordType) in deletions { - if let table = tablesByName[recordType] { - guard let recordName = SyncMetadata.RecordName(recordID: recordID) - else { - reportIssue( - """ - Received 'recordName' in invalid format: \(recordID.recordName) + for (recordID, recordType) in deletions { + if let table = tablesByName[recordType] { + guard let recordName = SyncMetadata.RecordName(recordID: recordID) + else { + reportIssue( + """ + Received 'recordName' in invalid format: \(recordID.recordName) - 'recordName' should be formatted as "uuid:tableName". - """ + 'recordName' should be formatted as "uuid:tableName". + """ + ) + continue + } + func open>(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + try T.find(recordName.id) + .delete() + .execute(db) + } + } + } + open(table) + } else if recordType == CKRecord.SystemType.share { + withErrorReporting { + try deleteShare(recordID: recordID, recordType: recordType) + } + } else { + // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? + reportIssue( + .sqliteDataCloudKitFailure.appending( + """ + : No table to delete from: "\(recordType)" + """ + ) ) - continue } - func open>(_: T.Type) { - withErrorReporting(.sqliteDataCloudKitFailure) { + } + } + } + + private func handleSentRecordZoneChanges( + _ event: CKSyncEngine.Event.SentRecordZoneChanges, + syncEngine: CKSyncEngine + ) { + for savedRecord in event.savedRecords { + refreshLastKnownServerRecord(savedRecord) + } + + var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] + var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] + defer { + syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + } + for failedRecordSave in event.failedRecordSaves { + let failedRecord = failedRecordSave.record + guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) + else { + reportIssue( + """ + Attempted to delete record with invalid 'recordName': \(failedRecord.recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) + continue + } + + func clearServerRecord() { + withErrorReporting { + try Self.$isUpdatingWithServerRecord.withValue(true) { try database.write { db in - try T.find(recordName.id) - .delete() + try SyncMetadata + .find(recordName) + .update { $0.lastKnownServerRecord = nil } .execute(db) } } } - open(table) - } else if recordType == CKRecord.SystemType.share { - withErrorReporting { - try deleteShare(recordID: recordID, recordType: recordType) - } - } else { - // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? - reportIssue( - .sqliteDataCloudKitFailure.appending( - """ - : No table to delete from: "\(recordType)" - """ - ) - ) + } + + switch failedRecordSave.error.code { + case .serverRecordChanged: + guard let serverRecord = failedRecordSave.error.serverRecord else { continue } + upsertFromServerRecord(serverRecord) + refreshLastKnownServerRecord(serverRecord) + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + + case .zoneNotFound: + let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) + // TODO: handle this + //newPendingDatabaseChanges.append(.saveZone(zone)) + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + clearServerRecord() + + case .unknownItem: + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + clearServerRecord() + + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, + .notAuthenticated, + .operationCancelled, .batchRequestFailed: + continue + + default: + continue } } + // TODO: handle event.failedRecordDeletes ? look at apple sample code } - } - private func handleSentRecordZoneChanges( - _ event: CKSyncEngine.Event.SentRecordZoneChanges, - syncEngine: CKSyncEngine - ) { - for savedRecord in event.savedRecords { - refreshLastKnownServerRecord(savedRecord) - } + private func cacheShare(_ share: CKShare) async throws { + guard let url = share.url + else { return } - var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] - var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] - defer { - syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) - syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) - } - for failedRecordSave in event.failedRecordSaves { - let failedRecord = failedRecordSave.record - guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) + guard + let metadata = try? await container.shareMetadata( + for: url, + shouldFetchRootRecord: true + ) + else { + // TODO: should we delete this record if it doesn't exist in the container? + return + } + + guard let rootRecord = metadata.rootRecord + else { return } + guard let recordName = SyncMetadata.RecordName(recordID: rootRecord.recordID) else { reportIssue( """ - Attempted to delete record with invalid 'recordName': \(failedRecord.recordID.recordName) + Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) 'recordName' should be formatted as "uuid:tableName". """ ) - continue + return } - func clearServerRecord() { - withErrorReporting { - try $isUpdatingWithServerRecord.withValue(true) { - try database.write { db in - try SyncMetadata - .find(recordName) - .update { $0.lastKnownServerRecord = nil } - .execute(db) - } - } + try await database.asyncWrite { db in + try SyncMetadata + .find(recordName) + .update { $0.share = share } + .execute(db) } - } - - switch failedRecordSave.error.code { - case .serverRecordChanged: - guard let serverRecord = failedRecordSave.error.serverRecord else { continue } - upsertFromServerRecord(serverRecord) - refreshLastKnownServerRecord(serverRecord) - newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - - case .zoneNotFound: - let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) - // TODO: handle this - //newPendingDatabaseChanges.append(.saveZone(zone)) - newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - clearServerRecord() - - case .unknownItem: - newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - clearServerRecord() - - case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, - .operationCancelled, .batchRequestFailed: - continue - - default: - continue - } - } - // TODO: handle event.failedRecordDeletes ? look at apple sample code - } - - private func cacheShare(_ share: CKShare) async throws { - guard let url = share.url - else { return } - - guard let metadata = try? await container.shareMetadata( - for: url, - shouldFetchRootRecord: true - ) - else { - // TODO: should we delete this record if it doesn't exist in the container? - return - } - - guard let rootRecord = metadata.rootRecord - else { return } - guard let recordName = SyncMetadata.RecordName(recordID: rootRecord.recordID) - else { - reportIssue( - """ - Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) - return } - try { + private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { + // TODO: more efficient way to do this? try database.write { db in - try SyncMetadata - .find(recordName) - .update { $0.share = share } + let metadata = + try SyncMetadata + .where { $0.share.isNot(nil) } + .fetchAll(db) + .first(where: { $0.share?.recordID == recordID }) ?? nil + guard let metadata + else { return } + try SyncMetadata.find(metadata.recordName) + .update { $0.share = nil } .execute(db) } - }() - } - - private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { - // TODO: more efficient way to do this? - try database.write { db in - let metadata = - try SyncMetadata - .where { $0.share.isNot(nil) } - .fetchAll(db) - .first(where: { $0.share?.recordID == recordID }) ?? nil - guard let metadata - else { return } - try SyncMetadata.find(metadata.recordName) - .update { $0.share = nil } - .execute(db) } - } - private func upsertFromServerRecord(_ record: CKRecord) { - $isUpdatingWithServerRecord.withValue(true) { - withErrorReporting(.sqliteDataCloudKitFailure) { - guard let table = tablesByName[record.recordType] - else { - // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? - reportIssue( - .sqliteDataCloudKitFailure.appending( + private func upsertFromServerRecord(_ record: CKRecord) { + Self.$isUpdatingWithServerRecord.withValue(true) { + withErrorReporting(.sqliteDataCloudKitFailure) { + guard let table = tablesByName[record.recordType] + else { + // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? + reportIssue( + .sqliteDataCloudKitFailure.appending( + """ + : No table to merge from: "\(record.recordType)" + """ + ) + ) + return + } + guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) + else { + reportIssue( """ - : No table to merge from: "\(record.recordType)" + Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". """ ) - ) - return + return + } + let userModificationDate = + try metadatabase.read { db in + try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( + db + ) + } + ?? nil + guard + let userModificationDate, + userModificationDate > record.userModificationDate ?? .distantPast + else { + // TODO: This should be fetched early and held onto (like 'ForeignKey') + let columnNames = try database.read { db in + try SQLQueryExpression( + """ + SELECT "name" + FROM pragma_table_info(\(bind: table.tableName)) + """, + as: String.self + ) + .fetchAll(db) + } + var query: QueryFragment = "INSERT INTO \(table) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + let encryptedValues = record.encryptedValues + query.append( + columnNames + .map { columnName in + if let asset = record[columnName] as? CKAsset { + return (try? asset.fileURL.map { try Data(contentsOf: $0) })? + .queryFragment ?? "NULL" + } else { + return encryptedValues[columnName]?.queryFragment ?? "NULL" + } + } + .joined(separator: ", ") + ) + func open(_: T.Type) -> String { + T.columns.primaryKey.name + } + let primaryKeyName = open(table) + query.append(") ON CONFLICT(\(quote: primaryKeyName)) DO UPDATE SET ") + + query.append( + columnNames + .filter { columnName in columnName != primaryKeyName } + .map { + """ + \(quote: $0) = "excluded".\(quote: $0) + """ + } + .joined(separator: ",") + ) + // TODO: Append more ON CONFLICT clauses for each unique constraint? + // TODO: Use WHERE to scope the update? + guard let metadata = SyncMetadata(record: record) + else { + reportIssue("???") + return + } + try database.write { db in + try SQLQueryExpression(query).execute(db) + try SyncMetadata + .insert { + metadata + } onConflictDoUpdate: { + $0.lastKnownServerRecord = record + $0.userModificationDate = record.userModificationDate + } + .execute(db) + } + return + } } + } + } + + private func refreshLastKnownServerRecord(_ record: CKRecord) { + Self.$isUpdatingWithServerRecord.withValue(true) { guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) else { reportIssue( @@ -863,356 +957,263 @@ extension SyncEngine: CKSyncEngineDelegate { ) return } - let userModificationDate = - try metadatabase.read { db in - try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( - db - ) - } - ?? nil - guard - let userModificationDate, - userModificationDate > record.userModificationDate ?? .distantPast - else { - // TODO: This should be fetched early and held onto (like 'ForeignKey') - let columnNames = try database.read { db in - try SQLQueryExpression( - """ - SELECT "name" - FROM pragma_table_info(\(bind: table.tableName)) - """, - as: String.self - ) - .fetchAll(db) - } - var query: QueryFragment = "INSERT INTO \(table) (" - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) - query.append(") VALUES (") - let encryptedValues = record.encryptedValues - query.append( - columnNames - .map { columnName in - if let asset = record[columnName] as? CKAsset { - return (try? asset.fileURL.map { try Data(contentsOf: $0) })? - .queryFragment ?? "NULL" - } else { - return encryptedValues[columnName]?.queryFragment ?? "NULL" - } - } - .joined(separator: ", ") - ) - func open(_: T.Type) -> String { - T.columns.primaryKey.name - } - let primaryKeyName = open(table) - query.append(") ON CONFLICT(\(quote: primaryKeyName)) DO UPDATE SET ") + let metadata = metadataFor(recordName: recordName) - query.append( - columnNames - .filter { columnName in columnName != primaryKeyName } - .map { - """ - \(quote: $0) = "excluded".\(quote: $0) - """ - } - .joined(separator: ",") - ) - // TODO: Append more ON CONFLICT clauses for each unique constraint? - // TODO: Use WHERE to scope the update? - guard let metadata = SyncMetadata(record: record) - else { - reportIssue("???") - return + func updateLastKnownServerRecord() { + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + try SyncMetadata + .find(recordName) + .update { $0.lastKnownServerRecord = record } + .execute(db) + } } - try database.write { db in - try SQLQueryExpression(query).execute(db) - try SyncMetadata - .insert { - metadata - } onConflictDoUpdate: { - $0.lastKnownServerRecord = record - $0.userModificationDate = record.userModificationDate - } - .execute(db) + } + + if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { + if let recordDate = record.modificationDate, lastKnownDate < recordDate { + updateLastKnownServerRecord() } - return + } else { + updateLastKnownServerRecord() } } } - } - private func refreshLastKnownServerRecord(_ record: CKRecord) { - $isUpdatingWithServerRecord.withValue(true) { - guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) - else { - reportIssue( - """ - Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) + private func metadataFor(recordName: SyncMetadata.RecordName) -> SyncMetadata? { + withErrorReporting(.sqliteDataCloudKitFailure) { + try metadatabase.read { db in + try SyncMetadata.find(recordName).fetchOne(db) + } + } + ?? nil + } + } - 'recordName' should be formatted as "uuid:tableName". - """ + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension DatabaseFunction { + fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { + Self("didUpdate") { recordName, zoneID in + syncEngine.didUpdate( + recordName: recordName, + zoneID: zoneID ) - return } - let metadata = metadataFor(recordName: recordName) + } - func updateLastKnownServerRecord() { - withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in - try SyncMetadata - .find(recordName) - .update { $0.lastKnownServerRecord = record } - .execute(db) - } - } + fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { + return Self("didDelete") { recordName, zoneID in + syncEngine.didDelete(recordName: recordName, zoneID: zoneID) } + } - if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { - if let recordDate = record.modificationDate, lastKnownDate < recordDate { - updateLastKnownServerRecord() - } - } else { - updateLastKnownServerRecord() + fileprivate static var datetime: Self { + Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in + @Dependency(\.date.now) var now + return now.formatted( + .iso8601 + .year().month().day() + .dateTimeSeparator(.space) + .time(includingFractionalSeconds: true) + ) } } - } - private func metadataFor(recordName: SyncMetadata.RecordName) -> SyncMetadata? { - withErrorReporting(.sqliteDataCloudKitFailure) { - try metadatabase.read { db in - try SyncMetadata.find(recordName).fetchOne(db) + fileprivate static var isUpdatingWithServerRecord: Self { + Self(.sqliteDataCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { + _ in + SyncEngine.isUpdatingWithServerRecord } } - ?? nil - } -} - -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension DatabaseFunction { - fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { recordName, zoneID in - syncEngine.didUpdate( - recordName: recordName, - zoneID: zoneID - ) + + private convenience init( + _ name: String, + function: @escaping @Sendable (SyncMetadata.RecordName, CKRecordZone.ID?) -> Void + ) { + self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 2) { arguments in + guard + let recordName = String.fromDatabaseValue(arguments[0]) + else { + return nil + } + guard let recordName = SyncMetadata.RecordName(rawValue: recordName) + else { + reportIssue( + """ + Received 'recordName' in invalid format: \(recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) + return nil + } + let zoneID = try Data.fromDatabaseValue(arguments[1]).flatMap { + let coder = try NSKeyedUnarchiver(forReadingFrom: $0) + coder.requiresSecureCoding = true + return CKRecord(coder: coder)?.recordID.zoneID + } + function(recordName, zoneID) + return nil + } } } - fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { - return Self("didDelete") { recordName, zoneID in - syncEngine.didDelete(recordName: recordName, zoneID: zoneID) - } + extension String { + package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" + fileprivate static let sqliteDataCloudKitFailure = "SharingGRDB CloudKit Failure" } - fileprivate static var datetime: Self { - Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in - @Dependency(\.date.now) var now - return now.formatted( - .iso8601 - .year().month().day() - .dateTimeSeparator(.space) - .time(includingFractionalSeconds: true) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension URL { + package static func metadatabase(containerIdentifier: String?) throws -> Self { + @Dependency(\.context) var context + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + let base: URL = + context == .live + ? .applicationSupportDirectory + : .temporaryDirectory + return base.appending( + component: "\(containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" ) } } - fileprivate static var isUpdatingWithServerRecord: Self { - Self(.sqliteDataCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { - _ in - SharingGRDBCore.isUpdatingWithServerRecord + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + struct SyncEngines { + let _private: (any SyncEngineProtocol)? + let _shared: (any SyncEngineProtocol)? + init() { + _private = nil + _shared = nil } - } - - private convenience init( - _ name: String, - function: @escaping @Sendable (SyncMetadata.RecordName, CKRecordZone.ID?) -> Void - ) { - self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 2) { arguments in - guard - let recordName = String.fromDatabaseValue(arguments[0]) + init(private: any SyncEngineProtocol, shared: any SyncEngineProtocol) { + self._private = `private` + self._shared = shared + } + var `private`: (any SyncEngineProtocol)? { + guard let _private else { + reportIssue("Private sync engine has not been set.") return nil } - guard let recordName = SyncMetadata.RecordName(rawValue: recordName) + return _private + } + var `shared`: (any SyncEngineProtocol)? { + guard let _shared else { - reportIssue( - """ - Received 'recordName' in invalid format: \(recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) + reportIssue("Shared sync engine has not been set.") return nil } - let zoneID = try Data.fromDatabaseValue(arguments[1]).flatMap { - let coder = try NSKeyedUnarchiver(forReadingFrom: $0) - coder.requiresSecureCoding = true - return CKRecord(coder: coder)?.recordID.zoneID - } - function(recordName, zoneID) - return nil + return _shared } } -} - -// TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates -@TaskLocal private var isUpdatingWithServerRecord = false - -extension String { - package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" - fileprivate static let sqliteDataCloudKitFailure = "SharingGRDB CloudKit Failure" -} - -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -extension URL { - package static func metadatabase(containerIdentifier: String?) throws -> Self { - @Dependency(\.context) var context - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - let base: URL = context == .live - ? .applicationSupportDirectory - : .temporaryDirectory - return base.appending( - component: "\(containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" - ) - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -struct SyncEngines { - let _private: (any SyncEngineProtocol)? - let _shared: (any SyncEngineProtocol)? - init() { - _private = nil - _shared = nil - } - init(private: any SyncEngineProtocol, shared: any SyncEngineProtocol) { - self._private = `private` - self._shared = shared - } - var `private`: (any SyncEngineProtocol)? { - guard let _private - else { - reportIssue("Private sync engine has not been set.") - return nil + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension Database { + /// Attaches the metadatabase to an existing database connection. + /// + /// Invoke this method when preparing your database connection in order to allow querying the + /// ``SyncMetadata`` table (see for more info): + /// + /// ```swift + /// func appDatabase() -> any DatabaseWriter { + /// var configuration = Configuration() + /// configuration.prepareDatabase = { db in + /// db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") + /// … + /// } + /// } + /// ``` + /// + /// See for more information on preparing your database. + /// + /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize + /// data. + public func attachMetadatabase(containerIdentifier: String) throws { + let url = try URL.metadatabase(containerIdentifier: containerIdentifier) + let path = url.path(percentEncoded: false) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + _ = try DatabasePool(path: path).write { db in + try SQLQueryExpression("SELECT 1").execute(db) + } + try SQLQueryExpression( + """ + ATTACH DATABASE \(bind: path) AS \(quote: .sqliteDataCloudKitSchemaName) + """ + ) + .execute(self) } - return _private } - var `shared`: (any SyncEngineProtocol)? { - guard let _shared - else { - reportIssue("Shared sync engine has not been set.") - return nil + + private func validateSchema( + tables: [any PrimaryKeyedTable.Type], + database: any DatabaseReader + ) throws { + try database.read { db in + for table in tables { + // // TODO: write tests for this + // let columnsWithUniqueConstraints = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + // WHERE "unique" = 1 AND "origin" <> 'pk' + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !columnsWithUniqueConstraints.isEmpty { + // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) + // } + + // // TODO: write tests for this + // let nonNullColumnsWithNoDefault = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) + // WHERE "notnull" = 1 AND "dflt_value" IS NULL + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !nonNullColumnsWithNoDefault.isEmpty { + // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) + // } + } } - return _shared } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Database { - /// Attaches the metadatabase to an existing database connection. - /// - /// Invoke this method when preparing your database connection in order to allow querying the - /// ``SyncMetadata`` table (see for more info): - /// - /// ```swift - /// func appDatabase() -> any DatabaseWriter { - /// var configuration = Configuration() - /// configuration.prepareDatabase = { db in - /// db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") - /// … - /// } - /// } - /// ``` - /// - /// See for more information on preparing your database. - /// - /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize - /// data. - public func attachMetadatabase(containerIdentifier: String) throws { - let url = try URL.metadatabase(containerIdentifier: containerIdentifier) - let path = url.path(percentEncoded: false) - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - _ = try DatabasePool(path: path).write { db in - try SQLQueryExpression("SELECT 1").execute(db) + + public struct UniqueConstraintDisallowed: Error { + let localizedDescription: String + init(table: any PrimaryKeyedTable.Type, columns: [String]) { + localizedDescription = """ + Table '\(table.tableName)' has column\(columns.count == 1 ? "" : "s") with unique \ + constraints: \(columns.map { "'\($0)'" }.joined(separator: ", ")) + """ } - try SQLQueryExpression( - """ - ATTACH DATABASE \(bind: path) AS \(quote: .sqliteDataCloudKitSchemaName) - """ - ) - .execute(self) } -} - -private func validateSchema( - tables: [any PrimaryKeyedTable.Type], - database: any DatabaseReader -) throws { - try database.read { db in - for table in tables { - // // TODO: write tests for this - // let columnsWithUniqueConstraints = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) - // WHERE "unique" = 1 AND "origin" <> 'pk' - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !columnsWithUniqueConstraints.isEmpty { - // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) - // } - - // // TODO: write tests for this - // let nonNullColumnsWithNoDefault = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) - // WHERE "notnull" = 1 AND "dflt_value" IS NULL - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !nonNullColumnsWithNoDefault.isEmpty { - // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) - // } + public struct NonNullColumnMustHaveDefault: Error { + let localizedDescription: String + init(table: any PrimaryKeyedTable.Type, columns: [String]) { + localizedDescription = """ + Table '\(table.tableName)' has non-null column\(columns.count == 1 ? "" : "s") with no \ + default: \(columns.map { "'\($0)'" }.joined(separator: ", ")) + """ } } -} - -public struct UniqueConstraintDisallowed: Error { - let localizedDescription: String - init(table: any PrimaryKeyedTable.Type, columns: [String]) { - localizedDescription = """ - Table '\(table.tableName)' has column\(columns.count == 1 ? "" : "s") with unique \ - constraints: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - """ - } -} -public struct NonNullColumnMustHaveDefault: Error { - let localizedDescription: String - init(table: any PrimaryKeyedTable.Type, columns: [String]) { - localizedDescription = """ - Table '\(table.tableName)' has non-null column\(columns.count == 1 ? "" : "s") with no \ - default: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - """ - } -} -private struct HashablePrimaryKeyedTableType: Hashable { - let type: any PrimaryKeyedTable.Type - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(type)) - } - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.type == rhs.type + private struct HashablePrimaryKeyedTableType: Hashable { + let type: any PrimaryKeyedTable.Type + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(type)) + } + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.type == rhs.type + } } -} #endif diff --git a/Sources/SharingGRDBCore/Internal/GRDBHelpers.swift b/Sources/SharingGRDBCore/Internal/GRDBHelpers.swift new file mode 100644 index 00000000..48c6f6ce --- /dev/null +++ b/Sources/SharingGRDBCore/Internal/GRDBHelpers.swift @@ -0,0 +1,23 @@ +import Dependencies +import GRDB + +extension DatabaseWriter { + // NB: The asynchronous 'write' method on 'DatabaseWriter' uses an escaping closure, which means + // task locals are lost when execute database queries. This method propagates certain task + // locals across that escaping closure boundary, which are used in our database triggers. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package func asyncWrite( + _ updates: @escaping @Sendable (Database) throws -> T + ) async throws -> T { + let currentIsUpdatingWithServerRecord = SyncEngine.isUpdatingWithServerRecord + return try await withEscapedDependencies { dependencies in + try await write { db in + try SyncEngine.$isUpdatingWithServerRecord.withValue(currentIsUpdatingWithServerRecord) { + try dependencies.yield { + try updates(db) + } + } + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index b2c06e8f..bef9accb 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -121,7 +121,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDown() async throws { - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -130,7 +130,7 @@ extension BaseCloudKitTests { .saveRecord(RemindersList.recordID(for: UUID(1))) ]) - try database.syncWrite { db in + try await database.asyncWrite { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 1) } @@ -146,7 +146,7 @@ extension BaseCloudKitTests { try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -170,7 +170,7 @@ extension BaseCloudKitTests { ) let metadata = - try database.syncWrite { db in + try await database.asyncWrite { db in try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) } #expect(metadata != nil) @@ -248,7 +248,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordUpdate() async throws { - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -262,7 +262,7 @@ extension BaseCloudKitTests { recordID: RemindersList.recordID(for: UUID(1)) ) let userModificationDate = try #require( - try database.syncWrite { db in + try await database.asyncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -282,7 +282,7 @@ extension BaseCloudKitTests { ) let metadata = try #require( - try database.syncWrite { db in + try await database.asyncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -294,7 +294,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordUpdateWithOldRecord() async throws { - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -307,7 +307,7 @@ extension BaseCloudKitTests { recordID: RemindersList.recordID(for: UUID(1)) ) let userModificationDate = try #require( - try database.syncWrite { db in + try await database.asyncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -327,7 +327,7 @@ extension BaseCloudKitTests { ) let metadata = try #require( - try database.syncWrite { db in + try await database.asyncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -338,7 +338,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordDeleted() async throws { - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -359,7 +359,7 @@ extension BaseCloudKitTests { try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() == 0 ) - let metadata = try database.syncWrite { db in + let metadata = try await database.asyncWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 732a1b0c..b4fc8503 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -19,7 +19,7 @@ extension BaseCloudKitTests { } @Test func initialSync() async throws { - let metadata = try database.syncRead { db in + let metadata = try await database.read { db in try SyncMetadata.all.order(by: \.primaryKey).fetchAll(db) } assertInlineSnapshot(of: metadata, as: .customDump) { diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 5a2e8dd5..03832599 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -34,7 +34,7 @@ extension BaseCloudKitTests { } @Test func nonExistentTable() async throws { - try database.syncWrite { db in + try await database.asyncWrite { db in try SyncMetadata.insert { SyncMetadata( recordType: UnrecognizedTable.tableName, @@ -82,7 +82,7 @@ extension BaseCloudKitTests { } @Test func metadataRowWithNoCorrespondingRecordRow() async throws { - try database.syncWrite { db in + try await database.asyncWrite { db in try SyncMetadata.insert { SyncMetadata( recordType: RemindersList.tableName, @@ -130,7 +130,7 @@ extension BaseCloudKitTests { } @Test func saveRecord() async throws { - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -190,7 +190,7 @@ extension BaseCloudKitTests { } @Test func saveRecordWithParent() async throws { - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) @@ -287,7 +287,7 @@ extension BaseCloudKitTests { } @Test func savePrivateRecord() async throws { - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") RemindersListPrivate(id: UUID(1), position: 42, remindersListID: UUID(1)) diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index def395eb..557570cd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -11,7 +11,7 @@ extension BaseCloudKitTests { final class RecordTypeTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() async throws { - let recordTypes = try database.syncWrite { db in + let recordTypes = try await database.asyncWrite { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: recordTypes, as: .customDump) { @@ -121,29 +121,29 @@ extension BaseCloudKitTests { @Test func tearDown() async throws { try await syncEngine.tearDownSyncEngine() - try database.syncWrite { db in + try await database.asyncWrite { db in try #expect(RecordType.all.fetchAll(db) == []) } } @Test func resetUp() async throws { - let recordTypes = try database.syncWrite { db in + let recordTypes = try await database.asyncWrite { db in try RecordType.all.fetchAll(db) } try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - let recordTypesAfterReSetup = try database.syncWrite { db in + let recordTypesAfterReSetup = try await database.asyncWrite { db in try RecordType.all.fetchAll(db) } expectNoDifference(recordTypes, recordTypesAfterReSetup) } @Test func migration() async throws { - let recordTypes = try database.syncWrite { db in + let recordTypes = try await database.asyncWrite { db in try RecordType.order(by: \.tableName).fetchAll(db) } try await syncEngine.tearDownSyncEngine() - try database.syncWrite { db in + try await database.asyncWrite { db in try #sql( """ ALTER TABLE "reminders" ADD COLUMN "newFeature" INTEGER NOT NULL @@ -153,7 +153,7 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - let recordTypesAfterMigration = try database.syncWrite { db in + let recordTypesAfterMigration = try await database.asyncWrite { db in try RecordType.order(by: \.tableName).fetchAll(db) } let remindersTableIndex = try #require( diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 77fbc608..63d57f59 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -13,7 +13,7 @@ extension BaseCloudKitTests { @Test func shareNonRootRecord() async throws { let reminder = Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) let user = User(id: UUID(1)) - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") reminder @@ -83,7 +83,7 @@ extension BaseCloudKitTests { deletions: [] ) - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) } @@ -160,7 +160,7 @@ extension BaseCloudKitTests { deletions: [] ) - try database.syncWrite { db in + try await database.asyncWrite { db in try Reminder.find(UUID(1)).delete().execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index d53bb47f..7d8f0109 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -14,7 +14,7 @@ extension BaseCloudKitTests { let personalList = RemindersList(id: UUID(1), title: "Personal") let businessList = RemindersList(id: UUID(2), title: "Business") let reminder = Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) - try database.syncWrite { db in + try await database.asyncWrite { db in try db.seed { personalList businessList @@ -51,7 +51,7 @@ extension BaseCloudKitTests { atomically: true ) - try database.syncWrite { db in + try await database.asyncWrite { db in try #sql( """ ALTER TABLE "remindersLists" @@ -75,10 +75,10 @@ extension BaseCloudKitTests { ) #expect(batch == nil) - let remindersLists = try database.syncRead { db in + let remindersLists = try await database.read { db in try MigratedRemindersList.order(by: \.id).fetchAll(db) } - let reminders = try database.syncRead { db in + let reminders = try await database.read { db in try MigratedReminder.order(by: \.id).fetchAll(db) } expectNoDifference( diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index f429a402..8a3af078 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -10,7 +10,7 @@ extension BaseCloudKitTests { final class TriggerTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func triggers() async throws { - let triggersAfterSetUp = try database.syncWrite { db in + let triggersAfterSetUp = try await database.asyncWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { @@ -459,7 +459,7 @@ extension BaseCloudKitTests { } try await syncEngine.tearDownSyncEngine() - let triggersAfterTearDown = try database.syncWrite { db in + let triggersAfterTearDown = try await database.asyncWrite { db in try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } assertInlineSnapshot(of: triggersAfterTearDown, as: .customDump) { @@ -469,7 +469,7 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - let triggersAfterReSetUp = try database.syncWrite { db in + let triggersAfterReSetUp = try await database.asyncWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } expectNoDifference(triggersAfterReSetUp, triggersAfterSetUp) diff --git a/Tests/SharingGRDBTests/FetchAllTests.swift b/Tests/SharingGRDBTests/FetchAllTests.swift index 51569b75..a3bda46d 100644 --- a/Tests/SharingGRDBTests/FetchAllTests.swift +++ b/Tests/SharingGRDBTests/FetchAllTests.swift @@ -15,7 +15,7 @@ struct FetchAllTests { @MainActor @Test func concurrency() async throws { let count = 1_000 - try await database.write { db in + try await database.asyncWrite { db in try Record.delete().execute(db) } @@ -24,7 +24,7 @@ struct FetchAllTests { await withThrowingTaskGroup { group in for index in 1...count { group.addTask { - try await database.write { db in + try await database.asyncWrite { db in try Record.insert { Record(id: index) }.execute(db) } } @@ -37,7 +37,7 @@ struct FetchAllTests { await withThrowingTaskGroup { group in for index in 1...(count / 2) { group.addTask { - try await database.write { db in + try await database.asyncWrite { db in try Record.find(index * 2).delete().execute(db) } } diff --git a/Tests/SharingGRDBTests/FetchOneTests.swift b/Tests/SharingGRDBTests/FetchOneTests.swift index 41cce897..e114189d 100644 --- a/Tests/SharingGRDBTests/FetchOneTests.swift +++ b/Tests/SharingGRDBTests/FetchOneTests.swift @@ -22,7 +22,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $record.load() } @@ -35,7 +35,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) #expect($record.loadError == nil) @@ -46,7 +46,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) #expect($record.loadError == nil) @@ -57,7 +57,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $record.load() } @@ -76,7 +76,7 @@ import Testing try await $recordDate.load() #expect(recordDate.timeIntervalSince1970 == 42) #expect($recordDate.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $recordDate.load() } @@ -95,7 +95,7 @@ import Testing try await $recordDate.load() #expect(recordDate?.timeIntervalSince1970 == 42) #expect($recordDate.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $recordDate.load() #expect(recordDate?.timeIntervalSince1970 == nil) #expect($recordDate.loadError == nil) @@ -110,7 +110,7 @@ import Testing try await $recordDate.load() #expect(recordDate?.timeIntervalSince1970 == nil) #expect($recordDate.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $recordDate.load() #expect(recordDate?.timeIntervalSince1970 == nil) #expect($recordDate.loadError == nil) @@ -125,7 +125,7 @@ import Testing try await $recordID.load() #expect(recordID == 1) #expect($recordID.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $recordID.load() } @@ -144,7 +144,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) #expect($record.loadError == nil) @@ -159,7 +159,7 @@ import Testing try await $id.load() #expect(id == nil) #expect($id.loadError == nil) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $id.load() #expect(id == nil) #expect($id.loadError == nil) diff --git a/Tests/SharingGRDBTests/FetchTests.swift b/Tests/SharingGRDBTests/FetchTests.swift index ecfcf946..790dd524 100644 --- a/Tests/SharingGRDBTests/FetchTests.swift +++ b/Tests/SharingGRDBTests/FetchTests.swift @@ -14,7 +14,7 @@ struct FetchTests { @FetchAll var records: [Record] #expect(records == [Record(id: 1), Record(id: 2), Record(id: 3)]) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $records.load() #expect(records == []) } @@ -23,7 +23,7 @@ struct FetchTests { @FetchAll(Record.where { $0.id > 1 }) var records: [Record] #expect(records == [Record(id: 2), Record(id: 3)]) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $records.load() #expect(records == []) } @@ -32,7 +32,7 @@ struct FetchTests { @FetchOne(Record.where { $0.id > 1 }.count()) var recordsCount = 0 #expect(recordsCount == 2) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $recordsCount.load() #expect(recordsCount == 0) } @@ -42,7 +42,7 @@ struct FetchTests { #expect(record == Record(id: 1)) print(#line) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) } @@ -52,7 +52,7 @@ struct FetchTests { try await $record.load() #expect(record == Record(id: 1)) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $record.load() } @@ -64,7 +64,7 @@ struct FetchTests { @FetchOne(#sql("SELECT * FROM records LIMIT 1")) var record: Record? #expect(record == Record(id: 1)) - try await database.write { try Record.delete().execute($0) } + try await database.asyncWrite { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) } diff --git a/Tests/SharingGRDBTests/IntegrationTests.swift b/Tests/SharingGRDBTests/IntegrationTests.swift index 70787c89..618873cd 100644 --- a/Tests/SharingGRDBTests/IntegrationTests.swift +++ b/Tests/SharingGRDBTests/IntegrationTests.swift @@ -14,19 +14,19 @@ struct IntegrationTests { @FetchAll(SyncUp.where(\.isActive)) var syncUps: [SyncUp] #expect(syncUps == []) - try await database.write { db in + try await database.asyncWrite { db in _ = try SyncUp.insert { SyncUp.Draft(isActive: true, title: "Engineering") } .execute(db) } try await $syncUps.load() #expect(syncUps == [SyncUp(id: 1, isActive: true, title: "Engineering")]) - try await database.write { db in + try await database.asyncWrite { db in _ = try SyncUp.upsert { SyncUp.Draft(id: 1, isActive: false, title: "Engineering") } .execute(db) } try await $syncUps.load() #expect(syncUps == []) - try await database.write { db in + try await database.asyncWrite { db in _ = try SyncUp.upsert { SyncUp.Draft(id: 1, isActive: true, title: "Engineering") } .execute(db) } @@ -39,19 +39,19 @@ struct IntegrationTests { @Fetch(ActiveSyncUps()) var syncUps: [SyncUp] = [] #expect(syncUps == []) - try await database.write { db in + try await database.asyncWrite { db in _ = try SyncUp.insert { SyncUp.Draft(isActive: true, title: "Engineering") } .execute(db) } try await $syncUps.load() #expect(syncUps == [SyncUp(id: 1, isActive: true, title: "Engineering")]) - try await database.write { db in + try await database.asyncWrite { db in _ = try SyncUp.upsert { SyncUp.Draft(id: 1, isActive: false, title: "Engineering") } .execute(db) } try await $syncUps.load() #expect(syncUps == []) - try await database.write { db in + try await database.asyncWrite { db in _ = try SyncUp.upsert { SyncUp.Draft(id: 1, isActive: true, title: "Engineering") } .execute(db) } diff --git a/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift b/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift deleted file mode 100644 index bf7f992c..00000000 --- a/Tests/SharingGRDBTests/Internal/GRDBHelpers.swift +++ /dev/null @@ -1,16 +0,0 @@ -import GRDB - -extension DatabaseWriter { - // TODO: Should we put this in the main library and use it everywhere? - // OR: should we make a version of 'write async' that propagates our task locals across - // the escaping boundary? - func syncWrite(_ updates: (Database) throws -> T) throws -> T { - try write(updates) - } -} - -extension DatabaseReader { - func syncRead(_ updates: (Database) throws -> T) throws -> T { - try read(updates) - } -} From c035c418e8cc2d2cf8afb46ff3978cefa6f45190 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 26 Jun 2025 20:20:06 -0700 Subject: [PATCH 227/581] Better handling of events. --- .../SharingGRDBCore/CloudKit/Logging.swift | 2 +- .../CloudKit/SyncEngine.Event.swift | 226 ++++++++++++++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 50 ++-- .../CloudKitTests/CloudKitTests.swift | 16 +- .../CloudKitTests/SharingTests.swift | 9 +- 5 files changed, 268 insertions(+), 35 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index 12f4127e..8e77d79a 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -4,7 +4,7 @@ import os @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Logger { - func log(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) { + func log(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) { let prefix = "[\(syncEngine.database.databaseScope.label)] handleEvent:" switch event { case .stateUpdate: diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift new file mode 100644 index 00000000..9a87724b --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -0,0 +1,226 @@ +#if canImport(CloudKit) + import CloudKit + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + package enum Event: CustomStringConvertible, Sendable { + case stateUpdate(StateUpdate) + case accountChange(AccountChange) + case fetchedDatabaseChanges(FetchedDatabaseChanges) + case fetchedRecordZoneChanges(FetchedRecordZoneChanges) + case sentDatabaseChanges(SentDatabaseChanges) + case sentRecordZoneChanges(SentRecordZoneChanges) + case willFetchChanges(WillFetchChanges) + case willFetchRecordZoneChanges(WillFetchRecordZoneChanges) + case didFetchRecordZoneChanges(DidFetchRecordZoneChanges) + case didFetchChanges(DidFetchChanges) + case willSendChanges(WillSendChanges) + case didSendChanges(DidSendChanges) + + init?(_ event: CKSyncEngine.Event) { + switch event { + case .stateUpdate(let event): + self = .stateUpdate(StateUpdate(stateSerialization: event.stateSerialization)) + case .accountChange(let event): + self = .accountChange(AccountChange(changeType: event.changeType)) + case .fetchedDatabaseChanges(let event): + self = .fetchedDatabaseChanges( + FetchedDatabaseChanges( + modifications: event.modifications.map { .init(zoneID: $0.zoneID) }, + deletions: event.deletions.map { .init(zoneID: $0.zoneID, reason: $0.reason) } + ) + ) + case .fetchedRecordZoneChanges(let event): + self = .fetchedRecordZoneChanges( + FetchedRecordZoneChanges.init( + modifications: event.modifications.map { .init(record: $0.record) }, + deletions: event.deletions.map { + .init(recordID: $0.recordID, recordType: $0.recordType) + } + ) + ) + case .sentDatabaseChanges(let event): + self = .sentDatabaseChanges( + SentDatabaseChanges.init( + savedZones: event.savedZones, + failedZoneSaves: event.failedZoneSaves.map { .init(zone: $0.zone, error: $0.error) }, + deletedZoneIDs: event.deletedZoneIDs, + failedZoneDeletes: event.failedZoneDeletes + ) + ) + case .sentRecordZoneChanges(let event): + self = .sentRecordZoneChanges( + SentRecordZoneChanges.init( + savedRecords: event.savedRecords, + failedRecordSaves: event.failedRecordSaves + .map { .init(record: $0.record, error: $0.error) }, + deletedRecordIDs: event.deletedRecordIDs, + failedRecordDeletes: event.failedRecordDeletes + ) + ) + case .willFetchChanges(let event): + if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { + self = .willFetchChanges(WillFetchChanges(context: event.context)) + } else { + self = .willFetchChanges(WillFetchChanges()) + } + case .willFetchRecordZoneChanges(let event): + self = .willFetchRecordZoneChanges(WillFetchRecordZoneChanges(zoneID: event.zoneID)) + case .didFetchRecordZoneChanges(let event): + self = .didFetchRecordZoneChanges( + DidFetchRecordZoneChanges( + zoneID: event.zoneID, + error: event.error + ) + ) + case .didFetchChanges(let event): + if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { + self = .didFetchChanges(DidFetchChanges(context: event.context)) + } else { + self = .didFetchChanges(DidFetchChanges()) + } + case .willSendChanges(let event): + self = .willSendChanges(WillSendChanges(context: event.context)) + case .didSendChanges(let event): + self = .didSendChanges(DidSendChanges(context: event.context)) + @unknown default: + return nil + } + } + + public var description: String { + switch self { + case .stateUpdate: + return "stateUpdate" + case .accountChange: + return "accountChange" + case .fetchedDatabaseChanges: + return "fetchedDatabaseChanges" + case .fetchedRecordZoneChanges: + return "fetchedRecordZoneChanges" + case .sentDatabaseChanges: + return "sentDatabaseChanges" + case .sentRecordZoneChanges: + return "sentRecordZoneChanges" + case .willFetchChanges: + return "willFetchChanges" + case .willFetchRecordZoneChanges: + return "willFetchRecordZoneChanges" + case .didFetchRecordZoneChanges: + return "didFetchRecordZoneChanges" + case .didFetchChanges: + return "didFetchChanges" + case .willSendChanges: + return "willSendChanges" + case .didSendChanges: + return "didSendChanges" + } + } + + package struct StateUpdate: Sendable { + package let stateSerialization: CKSyncEngine.State.Serialization + } + package struct AccountChange: Sendable { + package let changeType: CKSyncEngine.Event.AccountChange.ChangeType + } + package struct FetchedDatabaseChanges: Sendable { + package let modifications: [Modification] + package let deletions: [Deletion] + package struct Modification: Sendable { + package var zoneID: CKRecordZone.ID + } + package struct Deletion: Sendable { + package var zoneID: CKRecordZone.ID + package var reason: CKDatabase.DatabaseChange.Deletion.Reason + } + } + package struct FetchedRecordZoneChanges: Sendable { + package let modifications: [Modification] + package let deletions: [Deletion] + package struct Modification: Sendable { + package var record: CKRecord + package init(record: CKRecord) { + self.record = record + } + } + package struct Deletion: Sendable { + package var recordID: CKRecord.ID + package var recordType: CKRecord.RecordType + package init(recordID: CKRecord.ID, recordType: CKRecord.RecordType) { + self.recordID = recordID + self.recordType = recordType + } + } + package init(modifications: [Modification] = [], deletions: [Deletion] = []) { + self.modifications = modifications + self.deletions = deletions + } + } + package struct SentDatabaseChanges: Sendable { + package let savedZones: [CKRecordZone] + package let failedZoneSaves: [FailedZoneSave] + package let deletedZoneIDs: [CKRecordZone.ID] + package let failedZoneDeletes: [CKRecordZone.ID: CKError] + package struct FailedZoneSave: Sendable { + package let zone: CKRecordZone + package let error: CKError + } + } + package struct SentRecordZoneChanges: Sendable { + package let savedRecords: [CKRecord] + package let failedRecordSaves: [FailedRecordSave] + package let deletedRecordIDs: [CKRecord.ID] + package let failedRecordDeletes: [CKRecord.ID: CKError] + package struct FailedRecordSave: Sendable { + package let record: CKRecord + package let error: CKError + } + } + package struct WillFetchChanges: Sendable { + private var _context: (any Sendable)? + @available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) + package var context: CKSyncEngine.FetchChangesContext { + _context as! CKSyncEngine.FetchChangesContext + } + @available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) + init(context: CKSyncEngine.FetchChangesContext) { + _context = context + } + init() { + _context = nil + } + } + package struct FetchChangesContext: Sendable { + package let reason: CKSyncEngine.SyncReason + package let options: CKSyncEngine.FetchChangesOptions + } + package struct WillFetchRecordZoneChanges: Sendable { + package let zoneID: CKRecordZone.ID + } + package struct DidFetchRecordZoneChanges: Sendable { + package let zoneID: CKRecordZone.ID + package let error: CKError? + } + package struct DidFetchChanges: Sendable { + private var _context: (any Sendable)? + @available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) + package var context: CKSyncEngine.FetchChangesContext { + _context as! CKSyncEngine.FetchChangesContext + } + @available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) + init(context: CKSyncEngine.FetchChangesContext) { + _context = context + } + init() { + _context = nil + } + } + package struct WillSendChanges: Sendable { + package let context: CKSyncEngine.SendChangesContext + } + package struct DidSendChanges: Sendable { + package let context: CKSyncEngine.SendChangesContext + } + } + } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 761b731c..d5d2c384 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -400,6 +400,15 @@ extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine: CKSyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + guard let event = Event(event) + else { + reportIssue("Unrecognized event received: \(event)") + return + } + await handleEvent(event, syncEngine: syncEngine) + } + + package func handleEvent(_ event: Event, syncEngine: any SyncEngineProtocol) async { logger.log(event, syncEngine: syncEngine) switch event { @@ -412,10 +421,7 @@ extension SyncEngine: CKSyncEngineDelegate { case .sentDatabaseChanges: break case .fetchedRecordZoneChanges(let event): - await handleFetchedRecordZoneChanges( - modifications: event.modifications.map(\.record), - deletions: event.deletions.map { ($0.recordID, $0.recordType) } - ) + await handleFetchedRecordZoneChanges(event) case .sentRecordZoneChanges(let event): handleSentRecordZoneChanges(event, syncEngine: syncEngine) case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, @@ -569,7 +575,7 @@ extension SyncEngine: CKSyncEngineDelegate { return batch } - private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) async { + package func handleAccountChange(_ event: Event.AccountChange) async { switch event.changeType { case .signIn: syncEngines.withValue { @@ -609,9 +615,9 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func handleStateUpdate( - _ event: CKSyncEngine.Event.StateUpdate, - syncEngine: CKSyncEngine + package func handleStateUpdate( + _ event: Event.StateUpdate, + syncEngine: any SyncEngineProtocol ) { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in @@ -626,7 +632,7 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { + package func handleFetchedDatabaseChanges(_ event: Event.FetchedDatabaseChanges) { // TODO: How to handle this? $isUpdatingWithServerRecord.withValue(true) { withErrorReporting(.sqliteDataCloudKitFailure) { @@ -648,21 +654,19 @@ extension SyncEngine: CKSyncEngineDelegate { } } - package func handleFetchedRecordZoneChanges( - modifications: [CKRecord], - deletions: [(CKRecord.ID, CKRecord.RecordType)] - ) async { + package func handleFetchedRecordZoneChanges(_ event: Event.FetchedRecordZoneChanges) async { await $isUpdatingWithServerRecord.withValue(true) { - for record in modifications { - if let share = record as? CKShare { + for modification in event.modifications { + if let share = modification.record as? CKShare { await withErrorReporting { try await cacheShare(share) } } else { - upsertFromServerRecord(record) - refreshLastKnownServerRecord(record) + upsertFromServerRecord(modification.record) + refreshLastKnownServerRecord(modification.record) } - if let shareReference = record.share, + if let shareReference = modification.record.share, + // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in let shareRecord = try? await container.database(for: shareReference.recordID) .record(for: shareReference.recordID), let share = shareRecord as? CKShare @@ -673,7 +677,8 @@ extension SyncEngine: CKSyncEngineDelegate { } } - for (recordID, recordType) in deletions { + for deletion in event.deletions { + let (recordID, recordType) = (deletion.recordID, deletion.recordType) if let table = tablesByName[recordType] { guard let recordName = SyncMetadata.RecordName(recordID: recordID) else { @@ -714,9 +719,9 @@ extension SyncEngine: CKSyncEngineDelegate { } } - private func handleSentRecordZoneChanges( - _ event: CKSyncEngine.Event.SentRecordZoneChanges, - syncEngine: CKSyncEngine + package func handleSentRecordZoneChanges( + _ event: Event.SentRecordZoneChanges, + syncEngine: any SyncEngineProtocol ) { for savedRecord in event.savedRecords { refreshLastKnownServerRecord(savedRecord) @@ -1215,4 +1220,5 @@ private struct HashablePrimaryKeyedTableType: Hashable { lhs.type == rhs.type } } + #endif diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index b2c06e8f..4bd9ea13 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -160,10 +160,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", recordID: RemindersList.recordID(for: UUID(1)) ) - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [record], - deletions: [] - ) + await syncEngine.handleFetchedRecordZoneChanges(.init(modifications: [.init(record: record)])) expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Personal") @@ -275,7 +272,9 @@ extension BaseCloudKitTests { record.encryptedValues[RemindersList.columns.title.name] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(60) record.userModificationDate = serverModificationDate - await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) + await syncEngine.handleFetchedRecordZoneChanges( + .init(modifications: [.init(record: record)]) + ) expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Work") @@ -320,7 +319,9 @@ extension BaseCloudKitTests { record.encryptedValues[RemindersList.columns.title.name] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) record.userModificationDate = serverModificationDate - await syncEngine.handleFetchedRecordZoneChanges(modifications: [record], deletions: []) + await syncEngine.handleFetchedRecordZoneChanges( + .init(modifications: [.init(record: record)]) + ) expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Personal") @@ -352,8 +353,7 @@ extension BaseCloudKitTests { recordID: RemindersList.recordID(for: UUID(1)) ) await syncEngine.handleFetchedRecordZoneChanges( - modifications: [], - deletions: [(record.recordID, record.recordType)] + .init(deletions: [.init(recordID: record.recordID, recordType: record.recordType)]) ) #expect( try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 77fbc608..810f96bc 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -79,8 +79,7 @@ extension BaseCloudKitTests { remindersListRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1_234_567_890) await syncEngine.handleFetchedRecordZoneChanges( - modifications: [remindersListRecord], - deletions: [] + .init(modifications: [.init(record: remindersListRecord)]) ) try database.syncWrite { db in @@ -156,8 +155,10 @@ extension BaseCloudKitTests { reminderRecord.encryptedValues["remindersListID"] = UUID(1).uuidString.lowercased() remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1_234_567_890) await syncEngine.handleFetchedRecordZoneChanges( - modifications: [remindersListRecord, reminderRecord], - deletions: [] + .init(modifications: [ + .init(record: remindersListRecord), + .init(record: reminderRecord) + ]) ) try database.syncWrite { db in From 59c4d2beaac0078a5febe067229834d3c27a42fb Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 26 Jun 2025 20:33:13 -0700 Subject: [PATCH 228/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d5d2c384..9d8e1621 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -31,12 +31,12 @@ public final class SyncEngine: Sendable { ) throws { try self.init( container: container, - defaultSyncEngines: { database, syncEngine in + defaultSyncEngines: { metadatabase, syncEngine in ( private: CKSyncEngine( CKSyncEngine.Configuration( database: container.privateCloudDatabase, - stateSerialization: try? database.read { db in // TODO: write test for this + stateSerialization: try? metadatabase.read { db in try StateSerialization.find(CKDatabase.Scope.private).select(\.data).fetchOne(db) }, delegate: syncEngine @@ -45,7 +45,7 @@ public final class SyncEngine: Sendable { shared: CKSyncEngine( CKSyncEngine.Configuration( database: container.sharedCloudDatabase, - stateSerialization: try? database.read { db in // TODO: write test for this + stateSerialization: try? metadatabase.read { db in try StateSerialization.find(CKDatabase.Scope.shared).select(\.data).fetchOne(db) }, delegate: syncEngine From 517d99bd6c26f34294ea7c8412f34482e579065a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 27 Jun 2025 10:06:31 -0700 Subject: [PATCH 229/581] wip --- .../SharingGRDBCore/CloudKit/Logging.swift | 16 ++--- .../CloudKit/SendChangesContext.swift | 23 ++++++++ .../CloudKit/SyncEngine.Event.swift | 59 ++++++++++--------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 29 +++++---- .../CloudKit/SyncEngineProtocol.swift | 17 ------ .../CloudKitTests/SharingTests.swift | 3 +- 6 files changed, 80 insertions(+), 67 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/SendChangesContext.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index 8e77d79a..be040888 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -51,29 +51,29 @@ extension Logger { \(deletions) """ ) - case .fetchedRecordZoneChanges(let event): + case .fetchedRecordZoneChanges(let modifications, let deletions): let deletionsByRecordType = Dictionary( - grouping: event.deletions, + grouping: deletions, by: \.recordType ) let recordTypeDeletions = deletionsByRecordType.keys.sorted() .map { recordType in "\(recordType) (\(deletionsByRecordType[recordType]!.count))" } .joined(separator: ", ") let deletions = - event.deletions.isEmpty - ? "⚪️ No deletions" : "✅ Records deleted (\(event.deletions.count)): \(recordTypeDeletions)" + deletions.isEmpty + ? "⚪️ No deletions" : "✅ Records deleted (\(deletions.count)): \(recordTypeDeletions)" let modificationsByRecordType = Dictionary( - grouping: event.modifications, - by: \.record.recordType + grouping: modifications, + by: \.recordType ) let recordTypeModifications = modificationsByRecordType.keys.sorted() .map { recordType in "\(recordType) (\(modificationsByRecordType[recordType]!.count))" } .joined(separator: ", ") let modifications = - event.modifications.isEmpty + modifications.isEmpty ? "⚪️ No modifications" - : "✅ Records modified (\(event.modifications.count)): \(recordTypeModifications)" + : "✅ Records modified (\(modifications.count)): \(recordTypeModifications)" debug( """ diff --git a/Sources/SharingGRDBCore/CloudKit/SendChangesContext.swift b/Sources/SharingGRDBCore/CloudKit/SendChangesContext.swift new file mode 100644 index 00000000..f1b03dac --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/SendChangesContext.swift @@ -0,0 +1,23 @@ +#if canImport(CloudKit) + import CloudKit + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package struct SendChangesContext: Sendable { + package var reason: CKSyncEngine.SyncReason + package var options: CKSyncEngine.SendChangesOptions + package init( + reason: CKSyncEngine.SyncReason = .scheduled, + options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all) + ) { + self.reason = reason + self.options = options + } + init(context: CKSyncEngine.SendChangesContext) { + reason = context.reason + options = context.options + } + } + } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index 9a87724b..989a6f03 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -7,7 +7,10 @@ case stateUpdate(StateUpdate) case accountChange(AccountChange) case fetchedDatabaseChanges(FetchedDatabaseChanges) - case fetchedRecordZoneChanges(FetchedRecordZoneChanges) + case fetchedRecordZoneChanges( + modifications: [CKRecord], + deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] + ) case sentDatabaseChanges(SentDatabaseChanges) case sentRecordZoneChanges(SentRecordZoneChanges) case willFetchChanges(WillFetchChanges) @@ -32,12 +35,10 @@ ) case .fetchedRecordZoneChanges(let event): self = .fetchedRecordZoneChanges( - FetchedRecordZoneChanges.init( - modifications: event.modifications.map { .init(record: $0.record) }, - deletions: event.deletions.map { - .init(recordID: $0.recordID, recordType: $0.recordType) - } - ) + modifications: event.modifications.map(\.record), + deletions: event.deletions.map { + (recordID: $0.recordID, recordType: $0.recordType) + } ) case .sentDatabaseChanges(let event): self = .sentDatabaseChanges( @@ -134,28 +135,28 @@ package var reason: CKDatabase.DatabaseChange.Deletion.Reason } } - package struct FetchedRecordZoneChanges: Sendable { - package let modifications: [Modification] - package let deletions: [Deletion] - package struct Modification: Sendable { - package var record: CKRecord - package init(record: CKRecord) { - self.record = record - } - } - package struct Deletion: Sendable { - package var recordID: CKRecord.ID - package var recordType: CKRecord.RecordType - package init(recordID: CKRecord.ID, recordType: CKRecord.RecordType) { - self.recordID = recordID - self.recordType = recordType - } - } - package init(modifications: [Modification] = [], deletions: [Deletion] = []) { - self.modifications = modifications - self.deletions = deletions - } - } +// package struct FetchedRecordZoneChanges: Sendable { +// package let modifications: [Modification] +// package let deletions: [Deletion] +// package struct Modification: Sendable { +// package var record: CKRecord +// package init(record: CKRecord) { +// self.record = record +// } +// } +// package struct Deletion: Sendable { +// package var recordID: CKRecord.ID +// package var recordType: CKRecord.RecordType +// package init(recordID: CKRecord.ID, recordType: CKRecord.RecordType) { +// self.recordID = recordID +// self.recordType = recordType +// } +// } +// package init(modifications: [Modification] = [], deletions: [Deletion] = []) { +// self.modifications = modifications +// self.deletions = deletions +// } +// } package struct SentDatabaseChanges: Sendable { package let savedZones: [CKRecordZone] package let failedZoneSaves: [FailedZoneSave] diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 9d8e1621..868654d0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -420,8 +420,11 @@ extension SyncEngine: CKSyncEngineDelegate { handleFetchedDatabaseChanges(event) case .sentDatabaseChanges: break - case .fetchedRecordZoneChanges(let event): - await handleFetchedRecordZoneChanges(event) + case .fetchedRecordZoneChanges(let modifications, let deletions): + await handleFetchedRecordZoneChanges( + modifications: modifications, + deletions: deletions + ) case .sentRecordZoneChanges(let event): handleSentRecordZoneChanges(event, syncEngine: syncEngine) case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, @@ -436,13 +439,13 @@ extension SyncEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - await _nextRecordZoneChangeBatch( + await nextRecordZoneChangeBatch( SendChangesContext(context: context), syncEngine: syncEngine ) } - package func _nextRecordZoneChangeBatch( + package func nextRecordZoneChangeBatch( _ context: SendChangesContext, syncEngine: any SyncEngineProtocol ) async -> CKSyncEngine.RecordZoneChangeBatch? { @@ -654,18 +657,21 @@ extension SyncEngine: CKSyncEngineDelegate { } } - package func handleFetchedRecordZoneChanges(_ event: Event.FetchedRecordZoneChanges) async { + package func handleFetchedRecordZoneChanges( + modifications: [CKRecord] = [], + deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [] + ) async { await $isUpdatingWithServerRecord.withValue(true) { - for modification in event.modifications { - if let share = modification.record as? CKShare { + for record in modifications { + if let share = record as? CKShare { await withErrorReporting { try await cacheShare(share) } } else { - upsertFromServerRecord(modification.record) - refreshLastKnownServerRecord(modification.record) + upsertFromServerRecord(record) + refreshLastKnownServerRecord(record) } - if let shareReference = modification.record.share, + if let shareReference = record.share, // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in let shareRecord = try? await container.database(for: shareReference.recordID) .record(for: shareReference.recordID), @@ -677,8 +683,7 @@ extension SyncEngine: CKSyncEngineDelegate { } } - for deletion in event.deletions { - let (recordID, recordType) = (deletion.recordID, deletion.recordType) + for (recordID, recordType) in deletions { if let table = tablesByName[recordType] { guard let recordName = SyncMetadata.RecordName(recordID: recordID) else { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 1e39f3ef..447cd553 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -42,21 +42,4 @@ package protocol CKSyncEngineStateProtocol: Sendable { func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) } - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package struct SendChangesContext: Sendable { - package var reason: CKSyncEngine.SyncReason - package var options: CKSyncEngine.SendChangesOptions - package init( - reason: CKSyncEngine.SyncReason = .scheduled, - options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all) - ) { - self.reason = reason - self.options = options - } - init(context: CKSyncEngine.SendChangesContext) { - reason = context.reason - options = context.options - } -} #endif diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 810f96bc..1e3f7e57 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -79,7 +79,8 @@ extension BaseCloudKitTests { remindersListRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1_234_567_890) await syncEngine.handleFetchedRecordZoneChanges( - .init(modifications: [.init(record: remindersListRecord)]) + modifications: [remindersListRecord], + deletions: [] ) try database.syncWrite { db in From 210706349cc155887b686a4dbf8d048d79ffe1bb Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:11:56 -0700 Subject: [PATCH 230/581] wip --- Examples/Reminders/RemindersDetail.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index b677dd32..4490db90 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -15,7 +15,6 @@ class RemindersDetailModel: HashableObject { let detailType: DetailType var isNewReminderSheetPresented = false var sharedRecord: SharedRecord? - var sharedRecord: SharedRecord? @ObservationIgnored @Dependency(\.defaultDatabase) private var database @ObservationIgnored @Dependency(\.defaultSyncEngine) private var syncEngine From 568849b039479e52c66a474eeb0f8baa51f629bc Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 27 Jun 2025 10:22:03 -0700 Subject: [PATCH 231/581] fix --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index e79e3e92..98c93f78 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1028,7 +1028,7 @@ extension DatabaseFunction { fileprivate static var isUpdatingWithServerRecord: Self { Self(.sqliteDataCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { _ in - isUpdatingWithServerRecord + SyncEngine.isUpdatingWithServerRecord } } From 302296c5b1fef64cdf80107e8f8b30cc4b9304dc Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:28:56 -0700 Subject: [PATCH 232/581] wip --- .../CloudKitTests/CloudKitTests.swift | 12 ++++------ .../CloudKitTests/NewTableSyncTests.swift | 4 ++-- .../NextRecordZoneChangeBatchTests.swift | 24 +++++++++---------- .../CloudKitTests/SharingTests.swift | 18 +++++++------- .../CloudKitTests/SyncEngineSetUpTests.swift | 8 +++---- 5 files changed, 30 insertions(+), 36 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 27ca121f..b218348a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -160,7 +160,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", recordID: RemindersList.recordID(for: UUID(1)) ) - await syncEngine.handleFetchedRecordZoneChanges(.init(modifications: [.init(record: record)])) + await syncEngine.handleFetchedRecordZoneChanges(modifications: [record]) expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Personal") @@ -272,9 +272,7 @@ extension BaseCloudKitTests { record.encryptedValues[RemindersList.columns.title.name] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(60) record.userModificationDate = serverModificationDate - await syncEngine.handleFetchedRecordZoneChanges( - .init(modifications: [.init(record: record)]) - ) + await syncEngine.handleFetchedRecordZoneChanges(modifications: [record]) expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Work") @@ -319,9 +317,7 @@ extension BaseCloudKitTests { record.encryptedValues[RemindersList.columns.title.name] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) record.userModificationDate = serverModificationDate - await syncEngine.handleFetchedRecordZoneChanges( - .init(modifications: [.init(record: record)]) - ) + await syncEngine.handleFetchedRecordZoneChanges(modifications: [record]) expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Personal") @@ -353,7 +349,7 @@ extension BaseCloudKitTests { recordID: RemindersList.recordID(for: UUID(1)) ) await syncEngine.handleFetchedRecordZoneChanges( - .init(deletions: [.init(recordID: record.recordID, recordType: record.recordType)]) + deletions: [(recordID: record.recordID, recordType: record.recordType)] ) #expect( try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index b4fc8503..fb20b3cd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -53,8 +53,8 @@ extension BaseCloudKitTests { ] """ } - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext(), syncEngine: privateSyncEngine + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext(), syncEngine: privateSyncEngine ) assertInlineSnapshot(of: batch, as: .customDump) { """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 03832599..12a22fc5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -14,8 +14,8 @@ extension BaseCloudKitTests { pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: UUID(1)))] ) - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext( options: CKSyncEngine.SendChangesOptions( scope: .recordIDs([Reminder.recordID(for: UUID(1))]) ) @@ -62,8 +62,8 @@ extension BaseCloudKitTests { """ } - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext( options: CKSyncEngine.SendChangesOptions( scope: .recordIDs([UnrecognizedTable.recordID(for: UUID(1))]) ) @@ -110,8 +110,8 @@ extension BaseCloudKitTests { """ } - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext( options: CKSyncEngine.SendChangesOptions( scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) ) @@ -154,8 +154,8 @@ extension BaseCloudKitTests { """ } - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext( options: CKSyncEngine.SendChangesOptions( scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) ) @@ -224,8 +224,8 @@ extension BaseCloudKitTests { """ } - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext( options: CKSyncEngine.SendChangesOptions( scope: .recordIDs([ RemindersList.recordID(for: UUID(1)), @@ -321,8 +321,8 @@ extension BaseCloudKitTests { """ } - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext( options: CKSyncEngine.SendChangesOptions( scope: .recordIDs([ RemindersList.recordID(for: UUID(1)), diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 072b6a9e..8e1ec8d6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -89,8 +89,8 @@ extension BaseCloudKitTests { } } - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext( options: CKSyncEngine.SendChangesOptions( scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) ) @@ -155,19 +155,17 @@ extension BaseCloudKitTests { reminderRecord.encryptedValues["title"] = "Get milk" reminderRecord.encryptedValues["remindersListID"] = UUID(1).uuidString.lowercased() remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1_234_567_890) - await syncEngine.handleFetchedRecordZoneChanges( - .init(modifications: [ - .init(record: remindersListRecord), - .init(record: reminderRecord) - ]) - ) + await syncEngine.handleFetchedRecordZoneChanges(modifications: [ + remindersListRecord, + reminderRecord + ]) try await database.asyncWrite { db in try Reminder.find(UUID(1)).delete().execute(db) } - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext( + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext( options: CKSyncEngine.SendChangesOptions( scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index 7d8f0109..efa0a4b3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -21,8 +21,8 @@ extension BaseCloudKitTests { reminder } } - _ = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext(), + _ = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext(), syncEngine: privateSyncEngine ) @@ -69,8 +69,8 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - let batch = await syncEngine._nextRecordZoneChangeBatch( - SendChangesContext(), + let batch = await syncEngine.nextRecordZoneChangeBatch( + SyncEngine.SendChangesContext(), syncEngine: privateSyncEngine ) #expect(batch == nil) From f265ba52a73d44bd54cacf627c8e2cef3ddf5e23 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:30:19 -0700 Subject: [PATCH 233/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift | 4 ++-- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index 989a6f03..350fcb32 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -4,7 +4,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { package enum Event: CustomStringConvertible, Sendable { - case stateUpdate(StateUpdate) + case stateUpdate(stateSerialization: CKSyncEngine.State.Serialization) case accountChange(AccountChange) case fetchedDatabaseChanges(FetchedDatabaseChanges) case fetchedRecordZoneChanges( @@ -23,7 +23,7 @@ init?(_ event: CKSyncEngine.Event) { switch event { case .stateUpdate(let event): - self = .stateUpdate(StateUpdate(stateSerialization: event.stateSerialization)) + self = .stateUpdate(stateSerialization: event.stateSerialization) case .accountChange(let event): self = .accountChange(AccountChange(changeType: event.changeType)) case .fetchedDatabaseChanges(let event): diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 98c93f78..95c827e8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -415,8 +415,8 @@ extension SyncEngine: CKSyncEngineDelegate { switch event { case .accountChange(let event): await handleAccountChange(event) - case .stateUpdate(let event): - handleStateUpdate(event, syncEngine: syncEngine) + case .stateUpdate(let stateSerialization): + handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) case .fetchedDatabaseChanges(let event): handleFetchedDatabaseChanges(event) case .sentDatabaseChanges: @@ -620,7 +620,7 @@ extension SyncEngine: CKSyncEngineDelegate { } package func handleStateUpdate( - _ event: Event.StateUpdate, + stateSerialization: CKSyncEngine.State.Serialization, syncEngine: any SyncEngineProtocol ) { withErrorReporting(.sqliteDataCloudKitFailure) { @@ -628,7 +628,7 @@ extension SyncEngine: CKSyncEngineDelegate { try StateSerialization.upsert { StateSerialization.Draft( scope: syncEngine.database.databaseScope, - data: event.stateSerialization + data: stateSerialization ) } .execute(db) From c43f1cf3166978b39dc51a59a3285b684b799432 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:30:41 -0700 Subject: [PATCH 234/581] wip --- .../CloudKit/SyncEngine.Event.swift | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index 350fcb32..21033ba3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -118,9 +118,6 @@ } } - package struct StateUpdate: Sendable { - package let stateSerialization: CKSyncEngine.State.Serialization - } package struct AccountChange: Sendable { package let changeType: CKSyncEngine.Event.AccountChange.ChangeType } @@ -135,28 +132,6 @@ package var reason: CKDatabase.DatabaseChange.Deletion.Reason } } -// package struct FetchedRecordZoneChanges: Sendable { -// package let modifications: [Modification] -// package let deletions: [Deletion] -// package struct Modification: Sendable { -// package var record: CKRecord -// package init(record: CKRecord) { -// self.record = record -// } -// } -// package struct Deletion: Sendable { -// package var recordID: CKRecord.ID -// package var recordType: CKRecord.RecordType -// package init(recordID: CKRecord.ID, recordType: CKRecord.RecordType) { -// self.recordID = recordID -// self.recordType = recordType -// } -// } -// package init(modifications: [Modification] = [], deletions: [Deletion] = []) { -// self.modifications = modifications -// self.deletions = deletions -// } -// } package struct SentDatabaseChanges: Sendable { package let savedZones: [CKRecordZone] package let failedZoneSaves: [FailedZoneSave] From de84320c8a25dcfa4497d886d31145f72d62686b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:31:57 -0700 Subject: [PATCH 235/581] wip --- Sources/SharingGRDBCore/CloudKit/Logging.swift | 4 ++-- Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift | 7 ++----- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 ++++---- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index be040888..2ffd0cae 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -9,8 +9,8 @@ extension Logger { switch event { case .stateUpdate: debug("\(prefix) stateUpdate") - case .accountChange(let event): - switch event.changeType { + case .accountChange(let changeType): + switch changeType { case .signIn(let currentUser): debug( """ diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index 21033ba3..c4e28815 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -5,7 +5,7 @@ extension SyncEngine { package enum Event: CustomStringConvertible, Sendable { case stateUpdate(stateSerialization: CKSyncEngine.State.Serialization) - case accountChange(AccountChange) + case accountChange(changeType: CKSyncEngine.Event.AccountChange.ChangeType) case fetchedDatabaseChanges(FetchedDatabaseChanges) case fetchedRecordZoneChanges( modifications: [CKRecord], @@ -25,7 +25,7 @@ case .stateUpdate(let event): self = .stateUpdate(stateSerialization: event.stateSerialization) case .accountChange(let event): - self = .accountChange(AccountChange(changeType: event.changeType)) + self = .accountChange(changeType: event.changeType) case .fetchedDatabaseChanges(let event): self = .fetchedDatabaseChanges( FetchedDatabaseChanges( @@ -118,9 +118,6 @@ } } - package struct AccountChange: Sendable { - package let changeType: CKSyncEngine.Event.AccountChange.ChangeType - } package struct FetchedDatabaseChanges: Sendable { package let modifications: [Modification] package let deletions: [Deletion] diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 95c827e8..29d60615 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -413,8 +413,8 @@ extension SyncEngine: CKSyncEngineDelegate { logger.log(event, syncEngine: syncEngine) switch event { - case .accountChange(let event): - await handleAccountChange(event) + case .accountChange(let changeType): + await handleAccountChange(changeType: changeType) case .stateUpdate(let stateSerialization): handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) case .fetchedDatabaseChanges(let event): @@ -579,8 +579,8 @@ extension SyncEngine: CKSyncEngineDelegate { return batch } - package func handleAccountChange(_ event: Event.AccountChange) async { - switch event.changeType { + package func handleAccountChange(changeType: CKSyncEngine.Event.AccountChange.ChangeType) async { + switch changeType { case .signIn: syncEngines.withValue { $0.private?.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) From 72aebad05fb2ee01896456200c39f129a78ce930 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:36:16 -0700 Subject: [PATCH 236/581] wip --- .../CloudKit/SyncEngine.Event.swift | 49 ++++++------------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 13 +++-- 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index c4e28815..357523a9 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -6,12 +6,20 @@ package enum Event: CustomStringConvertible, Sendable { case stateUpdate(stateSerialization: CKSyncEngine.State.Serialization) case accountChange(changeType: CKSyncEngine.Event.AccountChange.ChangeType) - case fetchedDatabaseChanges(FetchedDatabaseChanges) + case fetchedDatabaseChanges( + modifications: [CKRecordZone.ID], + deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)] + ) case fetchedRecordZoneChanges( modifications: [CKRecord], deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] ) - case sentDatabaseChanges(SentDatabaseChanges) + case sentDatabaseChanges( + savedZones: [CKRecordZone], + failedZoneSaves: [(zone: CKRecordZone, error: CKError)], + deletedZoneIDs: [CKRecordZone.ID], + failedZoneDeletes: [CKRecordZone.ID: CKError] + ) case sentRecordZoneChanges(SentRecordZoneChanges) case willFetchChanges(WillFetchChanges) case willFetchRecordZoneChanges(WillFetchRecordZoneChanges) @@ -28,10 +36,8 @@ self = .accountChange(changeType: event.changeType) case .fetchedDatabaseChanges(let event): self = .fetchedDatabaseChanges( - FetchedDatabaseChanges( - modifications: event.modifications.map { .init(zoneID: $0.zoneID) }, - deletions: event.deletions.map { .init(zoneID: $0.zoneID, reason: $0.reason) } - ) + modifications: event.modifications.map(\.zoneID), + deletions: event.deletions.map { (zoneID: $0.zoneID, reason: $0.reason) } ) case .fetchedRecordZoneChanges(let event): self = .fetchedRecordZoneChanges( @@ -42,12 +48,10 @@ ) case .sentDatabaseChanges(let event): self = .sentDatabaseChanges( - SentDatabaseChanges.init( - savedZones: event.savedZones, - failedZoneSaves: event.failedZoneSaves.map { .init(zone: $0.zone, error: $0.error) }, - deletedZoneIDs: event.deletedZoneIDs, - failedZoneDeletes: event.failedZoneDeletes - ) + savedZones: event.savedZones, + failedZoneSaves: event.failedZoneSaves.map { (zone: $0.zone, error: $0.error) }, + deletedZoneIDs: event.deletedZoneIDs, + failedZoneDeletes: event.failedZoneDeletes ) case .sentRecordZoneChanges(let event): self = .sentRecordZoneChanges( @@ -118,27 +122,6 @@ } } - package struct FetchedDatabaseChanges: Sendable { - package let modifications: [Modification] - package let deletions: [Deletion] - package struct Modification: Sendable { - package var zoneID: CKRecordZone.ID - } - package struct Deletion: Sendable { - package var zoneID: CKRecordZone.ID - package var reason: CKDatabase.DatabaseChange.Deletion.Reason - } - } - package struct SentDatabaseChanges: Sendable { - package let savedZones: [CKRecordZone] - package let failedZoneSaves: [FailedZoneSave] - package let deletedZoneIDs: [CKRecordZone.ID] - package let failedZoneDeletes: [CKRecordZone.ID: CKError] - package struct FailedZoneSave: Sendable { - package let zone: CKRecordZone - package let error: CKError - } - } package struct SentRecordZoneChanges: Sendable { package let savedRecords: [CKRecord] package let failedRecordSaves: [FailedRecordSave] diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 29d60615..ad694025 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -417,8 +417,8 @@ extension SyncEngine: CKSyncEngineDelegate { await handleAccountChange(changeType: changeType) case .stateUpdate(let stateSerialization): handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) - case .fetchedDatabaseChanges(let event): - handleFetchedDatabaseChanges(event) + case .fetchedDatabaseChanges(let modifications, let deletions): + handleFetchedDatabaseChanges(modifications: modifications, deletions: deletions) case .sentDatabaseChanges: break case .fetchedRecordZoneChanges(let modifications, let deletions): @@ -636,12 +636,15 @@ extension SyncEngine: CKSyncEngineDelegate { } } - package func handleFetchedDatabaseChanges(_ event: Event.FetchedDatabaseChanges) { + package func handleFetchedDatabaseChanges( + modifications: [CKRecordZone.ID], + deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)] + ) { // TODO: How to handle this? Self.$isUpdatingWithServerRecord.withValue(true) { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in - for deletion in event.deletions { + for deletion in deletions { // if let table = tablesByName[deletion.zoneID.zoneName] { // func open(_: T.Type) { // withErrorReporting(.sqliteDataCloudKitFailure) { @@ -653,7 +656,7 @@ extension SyncEngine: CKSyncEngineDelegate { } // TODO: Deal with modifications? - _ = event.modifications + _ = modifications } } } From 7606110a2456becc21581f6cab344a5a82ac075c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:39:33 -0700 Subject: [PATCH 237/581] wip --- .../CloudKit/SyncEngine.Event.swift | 28 +++++++------------ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 24 ++++++++++++---- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index 357523a9..c4e56a47 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -20,7 +20,12 @@ deletedZoneIDs: [CKRecordZone.ID], failedZoneDeletes: [CKRecordZone.ID: CKError] ) - case sentRecordZoneChanges(SentRecordZoneChanges) + case sentRecordZoneChanges( + savedRecords: [CKRecord], + failedRecordSaves: [(record: CKRecord, error: CKError)], + deletedRecordIDs: [CKRecord.ID], + failedRecordDeletes: [CKRecord.ID: CKError] + ) case willFetchChanges(WillFetchChanges) case willFetchRecordZoneChanges(WillFetchRecordZoneChanges) case didFetchRecordZoneChanges(DidFetchRecordZoneChanges) @@ -55,13 +60,10 @@ ) case .sentRecordZoneChanges(let event): self = .sentRecordZoneChanges( - SentRecordZoneChanges.init( - savedRecords: event.savedRecords, - failedRecordSaves: event.failedRecordSaves - .map { .init(record: $0.record, error: $0.error) }, - deletedRecordIDs: event.deletedRecordIDs, - failedRecordDeletes: event.failedRecordDeletes - ) + savedRecords: event.savedRecords, + failedRecordSaves: event.failedRecordSaves.map { (record: $0.record, error: $0.error) }, + deletedRecordIDs: event.deletedRecordIDs, + failedRecordDeletes: event.failedRecordDeletes ) case .willFetchChanges(let event): if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { @@ -122,16 +124,6 @@ } } - package struct SentRecordZoneChanges: Sendable { - package let savedRecords: [CKRecord] - package let failedRecordSaves: [FailedRecordSave] - package let deletedRecordIDs: [CKRecord.ID] - package let failedRecordDeletes: [CKRecord.ID: CKError] - package struct FailedRecordSave: Sendable { - package let record: CKRecord - package let error: CKError - } - } package struct WillFetchChanges: Sendable { private var _context: (any Sendable)? @available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ad694025..50fa55e0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -426,8 +426,19 @@ extension SyncEngine: CKSyncEngineDelegate { modifications: modifications, deletions: deletions ) - case .sentRecordZoneChanges(let event): - handleSentRecordZoneChanges(event, syncEngine: syncEngine) + case .sentRecordZoneChanges( + let savedRecords, + let failedRecordSaves, + let deletedRecordIDs, + let failedRecordDeletes + ): + handleSentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes, + syncEngine: syncEngine + ) case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, .didFetchChanges, .willSendChanges, .didSendChanges: break @@ -729,10 +740,13 @@ extension SyncEngine: CKSyncEngineDelegate { } package func handleSentRecordZoneChanges( - _ event: Event.SentRecordZoneChanges, + savedRecords: [CKRecord] = [], + failedRecordSaves: [(record: CKRecord, error: CKError)] = [], + deletedRecordIDs: [CKRecord.ID] = [], + failedRecordDeletes: [CKRecord.ID: CKError] = [:], syncEngine: any SyncEngineProtocol ) { - for savedRecord in event.savedRecords { + for savedRecord in savedRecords { refreshLastKnownServerRecord(savedRecord) } @@ -742,7 +756,7 @@ extension SyncEngine: CKSyncEngineDelegate { syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } - for failedRecordSave in event.failedRecordSaves { + for failedRecordSave in failedRecordSaves { let failedRecord = failedRecordSave.record guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) else { From 33339c0389ff3d3306381d3d78e9b9766c0dc516 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:41:14 -0700 Subject: [PATCH 238/581] wip --- .../SharingGRDBCore/CloudKit/Logging.swift | 8 ++----- .../CloudKit/SyncEngine.Event.swift | 24 +++---------------- 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index 2ffd0cae..df4b6088 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -156,12 +156,8 @@ extension Logger { \(event.failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(event.failedRecordDeletes.count))") """ ) - case .willFetchChanges(let event): - if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { - debug("\(prefix) willFetchChanges: \(event.context.reason.description)") - } else { - debug("\(prefix) willFetchChanges") - } + case .willFetchChanges: + debug("\(prefix) willFetchChanges") case .willFetchRecordZoneChanges(let event): debug("\(prefix) willFetchRecordZoneChanges: \(event.zoneID.zoneName)") case .didFetchRecordZoneChanges(let event): diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index c4e56a47..4705767d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -26,7 +26,7 @@ deletedRecordIDs: [CKRecord.ID], failedRecordDeletes: [CKRecord.ID: CKError] ) - case willFetchChanges(WillFetchChanges) + case willFetchChanges case willFetchRecordZoneChanges(WillFetchRecordZoneChanges) case didFetchRecordZoneChanges(DidFetchRecordZoneChanges) case didFetchChanges(DidFetchChanges) @@ -65,12 +65,8 @@ deletedRecordIDs: event.deletedRecordIDs, failedRecordDeletes: event.failedRecordDeletes ) - case .willFetchChanges(let event): - if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { - self = .willFetchChanges(WillFetchChanges(context: event.context)) - } else { - self = .willFetchChanges(WillFetchChanges()) - } + case .willFetchChanges: + self = .willFetchChanges case .willFetchRecordZoneChanges(let event): self = .willFetchRecordZoneChanges(WillFetchRecordZoneChanges(zoneID: event.zoneID)) case .didFetchRecordZoneChanges(let event): @@ -124,20 +120,6 @@ } } - package struct WillFetchChanges: Sendable { - private var _context: (any Sendable)? - @available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) - package var context: CKSyncEngine.FetchChangesContext { - _context as! CKSyncEngine.FetchChangesContext - } - @available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) - init(context: CKSyncEngine.FetchChangesContext) { - _context = context - } - init() { - _context = nil - } - } package struct FetchChangesContext: Sendable { package let reason: CKSyncEngine.SyncReason package let options: CKSyncEngine.FetchChangesOptions From bfc494a52c48635747316407e65f4b6b8cd9b3ca Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:43:32 -0700 Subject: [PATCH 239/581] wip --- Sources/SharingGRDBCore/CloudKit/Logging.swift | 12 ++++++------ .../SharingGRDBCore/CloudKit/SyncEngine.Event.swift | 11 ++--------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index df4b6088..06496d45 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -36,12 +36,12 @@ extension Logger { @unknown default: debug("unknown") } - case .fetchedDatabaseChanges(let event): + case .fetchedDatabaseChanges(_, let deletions): let deletions = - event.deletions.isEmpty + deletions.isEmpty ? "⚪️ No deletions" - : "✅ Zones deleted (\(event.deletions.count)): " - + event.deletions + : "✅ Zones deleted (\(deletions.count)): " + + deletions .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } .sorted() .joined(separator: ", ") @@ -158,8 +158,8 @@ extension Logger { ) case .willFetchChanges: debug("\(prefix) willFetchChanges") - case .willFetchRecordZoneChanges(let event): - debug("\(prefix) willFetchRecordZoneChanges: \(event.zoneID.zoneName)") + case .willFetchRecordZoneChanges(let zoneID): + debug("\(prefix) willFetchRecordZoneChanges: \(zoneID.zoneName)") case .didFetchRecordZoneChanges(let event): let errorType = event.error.map { switch $0.code { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index 4705767d..dbea8434 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -27,7 +27,7 @@ failedRecordDeletes: [CKRecord.ID: CKError] ) case willFetchChanges - case willFetchRecordZoneChanges(WillFetchRecordZoneChanges) + case willFetchRecordZoneChanges(zoneID: CKRecordZone.ID) case didFetchRecordZoneChanges(DidFetchRecordZoneChanges) case didFetchChanges(DidFetchChanges) case willSendChanges(WillSendChanges) @@ -68,7 +68,7 @@ case .willFetchChanges: self = .willFetchChanges case .willFetchRecordZoneChanges(let event): - self = .willFetchRecordZoneChanges(WillFetchRecordZoneChanges(zoneID: event.zoneID)) + self = .willFetchRecordZoneChanges(zoneID: event.zoneID) case .didFetchRecordZoneChanges(let event): self = .didFetchRecordZoneChanges( DidFetchRecordZoneChanges( @@ -120,13 +120,6 @@ } } - package struct FetchChangesContext: Sendable { - package let reason: CKSyncEngine.SyncReason - package let options: CKSyncEngine.FetchChangesOptions - } - package struct WillFetchRecordZoneChanges: Sendable { - package let zoneID: CKRecordZone.ID - } package struct DidFetchRecordZoneChanges: Sendable { package let zoneID: CKRecordZone.ID package let error: CKError? From 09f3766b09fada02d10bb26ad175986b192047ad Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:45:13 -0700 Subject: [PATCH 240/581] wip --- .../SharingGRDBCore/CloudKit/Logging.swift | 8 +--- .../CloudKit/SyncEngine.Event.swift | 37 +++---------------- 2 files changed, 7 insertions(+), 38 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index 06496d45..120c779f 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -209,12 +209,8 @@ extension Logger { ✅ Zone: \(event.zoneID.zoneName):\(event.zoneID.ownerName)\(error) """ ) - case .didFetchChanges(let event): - if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { - debug("\(prefix) didFetchChanges: \(event.context.reason.description)") - } else { - debug("\(prefix) didFetchChanges") - } + case .didFetchChanges: + debug("\(prefix) didFetchChanges") case .willSendChanges(let event): debug("\(prefix) willSendChanges: \(event.context.reason.description)") case .didSendChanges(let event): diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index dbea8434..3083b240 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -28,8 +28,8 @@ ) case willFetchChanges case willFetchRecordZoneChanges(zoneID: CKRecordZone.ID) - case didFetchRecordZoneChanges(DidFetchRecordZoneChanges) - case didFetchChanges(DidFetchChanges) + case didFetchRecordZoneChanges(zoneID: CKRecordZone.ID, error: CKError?) + case didFetchChanges case willSendChanges(WillSendChanges) case didSendChanges(DidSendChanges) @@ -70,18 +70,9 @@ case .willFetchRecordZoneChanges(let event): self = .willFetchRecordZoneChanges(zoneID: event.zoneID) case .didFetchRecordZoneChanges(let event): - self = .didFetchRecordZoneChanges( - DidFetchRecordZoneChanges( - zoneID: event.zoneID, - error: event.error - ) - ) - case .didFetchChanges(let event): - if #available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) { - self = .didFetchChanges(DidFetchChanges(context: event.context)) - } else { - self = .didFetchChanges(DidFetchChanges()) - } + self = .didFetchRecordZoneChanges(zoneID: event.zoneID, error: event.error) + case .didFetchChanges: + self = .didFetchChanges case .willSendChanges(let event): self = .willSendChanges(WillSendChanges(context: event.context)) case .didSendChanges(let event): @@ -120,24 +111,6 @@ } } - package struct DidFetchRecordZoneChanges: Sendable { - package let zoneID: CKRecordZone.ID - package let error: CKError? - } - package struct DidFetchChanges: Sendable { - private var _context: (any Sendable)? - @available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) - package var context: CKSyncEngine.FetchChangesContext { - _context as! CKSyncEngine.FetchChangesContext - } - @available(macOS 14.2, iOS 17.2, tvOS 17.2, watchOS 10.2, *) - init(context: CKSyncEngine.FetchChangesContext) { - _context = context - } - init() { - _context = nil - } - } package struct WillSendChanges: Sendable { package let context: CKSyncEngine.SendChangesContext } From 055f3319412233d3e457d7d257b47cf29a62bc00 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:46:43 -0700 Subject: [PATCH 241/581] wip --- .../SharingGRDBCore/CloudKit/Logging.swift | 8 +++---- .../CloudKit/SyncEngine.Event.swift | 21 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index 120c779f..8c1b3c13 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -211,10 +211,10 @@ extension Logger { ) case .didFetchChanges: debug("\(prefix) didFetchChanges") - case .willSendChanges(let event): - debug("\(prefix) willSendChanges: \(event.context.reason.description)") - case .didSendChanges(let event): - debug("\(prefix) didSendChanges: \(event.context.reason.description)") + case .willSendChanges(let context): + debug("\(prefix) willSendChanges: \(context.reason.description)") + case .didSendChanges(let context): + debug("\(prefix) didSendChanges: \(context.reason.description)") @unknown default: warning("\(prefix) ⚠️ unknown event: \(event.description)") } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index 3083b240..04baaf7c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -28,10 +28,10 @@ ) case willFetchChanges case willFetchRecordZoneChanges(zoneID: CKRecordZone.ID) - case didFetchRecordZoneChanges(zoneID: CKRecordZone.ID, error: CKError?) case didFetchChanges - case willSendChanges(WillSendChanges) - case didSendChanges(DidSendChanges) + case didFetchRecordZoneChanges(zoneID: CKRecordZone.ID, error: CKError?) + case willSendChanges(context: CKSyncEngine.SendChangesContext) + case didSendChanges(context: CKSyncEngine.SendChangesContext) init?(_ event: CKSyncEngine.Event) { switch event { @@ -69,14 +69,14 @@ self = .willFetchChanges case .willFetchRecordZoneChanges(let event): self = .willFetchRecordZoneChanges(zoneID: event.zoneID) - case .didFetchRecordZoneChanges(let event): - self = .didFetchRecordZoneChanges(zoneID: event.zoneID, error: event.error) case .didFetchChanges: self = .didFetchChanges + case .didFetchRecordZoneChanges(let event): + self = .didFetchRecordZoneChanges(zoneID: event.zoneID, error: event.error) case .willSendChanges(let event): - self = .willSendChanges(WillSendChanges(context: event.context)) + self = .willSendChanges(context: event.context) case .didSendChanges(let event): - self = .didSendChanges(DidSendChanges(context: event.context)) + self = .didSendChanges(context: event.context) @unknown default: return nil } @@ -110,13 +110,6 @@ return "didSendChanges" } } - - package struct WillSendChanges: Sendable { - package let context: CKSyncEngine.SendChangesContext - } - package struct DidSendChanges: Sendable { - package let context: CKSyncEngine.SendChangesContext - } } } #endif From 58a454355279dffc0a5634f503d5bdae730cb8d1 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:49:28 -0700 Subject: [PATCH 242/581] wip --- .../SharingGRDBCore/CloudKit/Logging.swift | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SharingGRDBCore/CloudKit/Logging.swift index 8c1b3c13..b62caf4c 100644 --- a/Sources/SharingGRDBCore/CloudKit/Logging.swift +++ b/Sources/SharingGRDBCore/CloudKit/Logging.swift @@ -82,42 +82,47 @@ extension Logger { \(deletions) """ ) - case .sentDatabaseChanges(let event): - let savedZoneNames = event.savedZones + case .sentDatabaseChanges( + let savedZones, + let failedZoneSaves, + let deletedZoneIDs, + let failedZoneDeletes + ): + let savedZoneNames = savedZones .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } .sorted() .joined(separator: ", ") let savedZones = - event.savedZones.isEmpty - ? "⚪️ No saved zones" : "✅ Saved zones (\(event.savedZones.count)): \(savedZoneNames)" + savedZones.isEmpty + ? "⚪️ No saved zones" : "✅ Saved zones (\(savedZones.count)): \(savedZoneNames)" - let deletedZoneNames = event.deletedZoneIDs + let deletedZoneNames = deletedZoneIDs .map { $0.zoneName } .sorted() .joined(separator: ", ") let deletedZones = - event.deletedZoneIDs.isEmpty + deletedZoneIDs.isEmpty ? "⚪️ No deleted zones" - : "✅ Deleted zones (\(event.deletedZoneIDs.count)): \(deletedZoneNames)" + : "✅ Deleted zones (\(deletedZoneIDs.count)): \(deletedZoneNames)" - let failedZoneSaveNames = event.failedZoneSaves + let failedZoneSaveNames = failedZoneSaves .map { $0.zone.zoneID.zoneName + ":" + $0.zone.zoneID.ownerName } .sorted() .joined(separator: ", ") let failedZoneSaves = - event.failedZoneSaves.isEmpty + failedZoneSaves.isEmpty ? "⚪️ No failed saved zones" - : "🛑 Failed zone saves (\(event.failedZoneSaves.count)): \(failedZoneSaveNames)" + : "🛑 Failed zone saves (\(failedZoneSaves.count)): \(failedZoneSaveNames)" - let failedZoneDeleteNames = event.failedZoneDeletes + let failedZoneDeleteNames = failedZoneDeletes .keys .map { $0.zoneName } .sorted() .joined(separator: ", ") let failedZoneDeletes = - event.failedZoneDeletes.isEmpty + failedZoneDeletes.isEmpty ? "⚪️ No failed deleted zones" - : "🛑 Failed zone delete (\(event.failedZoneDeletes.count)): \(failedZoneDeleteNames)" + : "🛑 Failed zone delete (\(failedZoneDeletes.count)): \(failedZoneDeleteNames)" debug( """ @@ -128,9 +133,14 @@ extension Logger { \(failedZoneDeletes) """ ) - case .sentRecordZoneChanges(let event): + case .sentRecordZoneChanges( + let savedRecords, + let failedRecordSaves, + let deletedRecordIDs, + let failedRecordDeletes + ): let savedRecordsByRecordType = Dictionary( - grouping: event.savedRecords, + grouping: savedRecords, by: \.recordType ) let savedRecords = savedRecordsByRecordType.keys @@ -139,7 +149,7 @@ extension Logger { .joined(separator: ", ") let failedRecordSavesByZoneName = Dictionary( - grouping: event.failedRecordSaves, + grouping: failedRecordSaves, by: { $0.record.recordID.zoneID.zoneName + ":" + $0.record.recordID.zoneID.ownerName } ) let failedRecordSaves = failedRecordSavesByZoneName.keys @@ -151,17 +161,17 @@ extension Logger { """ \(prefix) sentRecordZoneChanges \(savedRecordsByRecordType.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") - \(event.deletedRecordIDs.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records (\(event.deletedRecordIDs.count))") + \(deletedRecordIDs.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records (\(deletedRecordIDs.count))") \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)") - \(event.failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(event.failedRecordDeletes.count))") + \(failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(failedRecordDeletes.count))") """ ) case .willFetchChanges: debug("\(prefix) willFetchChanges") case .willFetchRecordZoneChanges(let zoneID): debug("\(prefix) willFetchRecordZoneChanges: \(zoneID.zoneName)") - case .didFetchRecordZoneChanges(let event): - let errorType = event.error.map { + case .didFetchRecordZoneChanges(let zoneID, let error): + let errorType = error.map { switch $0.code { case .internalError: "internalError" case .partialFailure: "partialFailure" @@ -206,7 +216,7 @@ extension Logger { debug( """ \(prefix) willFetchRecordZoneChanges - ✅ Zone: \(event.zoneID.zoneName):\(event.zoneID.ownerName)\(error) + ✅ Zone: \(zoneID.zoneName):\(zoneID.ownerName)\(error) """ ) case .didFetchChanges: From 78f7ea5d1b1d79cfe93e1dfcc27f4ec5bb1fd7ce Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:53:46 -0700 Subject: [PATCH 243/581] wip --- .../CloudKit/SyncEngine.Event.swift | 36 +++++++------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift index 04baaf7c..74d8de9b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift @@ -84,30 +84,18 @@ public var description: String { switch self { - case .stateUpdate: - return "stateUpdate" - case .accountChange: - return "accountChange" - case .fetchedDatabaseChanges: - return "fetchedDatabaseChanges" - case .fetchedRecordZoneChanges: - return "fetchedRecordZoneChanges" - case .sentDatabaseChanges: - return "sentDatabaseChanges" - case .sentRecordZoneChanges: - return "sentRecordZoneChanges" - case .willFetchChanges: - return "willFetchChanges" - case .willFetchRecordZoneChanges: - return "willFetchRecordZoneChanges" - case .didFetchRecordZoneChanges: - return "didFetchRecordZoneChanges" - case .didFetchChanges: - return "didFetchChanges" - case .willSendChanges: - return "willSendChanges" - case .didSendChanges: - return "didSendChanges" + case .stateUpdate: "stateUpdate" + case .accountChange: "accountChange" + case .fetchedDatabaseChanges: "fetchedDatabaseChanges" + case .fetchedRecordZoneChanges: "fetchedRecordZoneChanges" + case .sentDatabaseChanges: "sentDatabaseChanges" + case .sentRecordZoneChanges: "sentRecordZoneChanges" + case .willFetchChanges: "willFetchChanges" + case .willFetchRecordZoneChanges: "willFetchRecordZoneChanges" + case .didFetchRecordZoneChanges: "didFetchRecordZoneChanges" + case .didFetchChanges: "didFetchChanges" + case .willSendChanges: "willSendChanges" + case .didSendChanges: "didSendChanges" } } } From fcb54811eb47425f4dc4f3451447881087bffa8d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 10:58:37 -0700 Subject: [PATCH 244/581] wip --- .../CloudKit/SendChangesContext.swift | 23 --------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 12 ++--- .../CloudKitTests/NewTableSyncTests.swift | 4 +- .../NextRecordZoneChangeBatchTests.swift | 48 +++++++------------ .../CloudKitTests/SharingTests.swift | 12 ++--- .../CloudKitTests/SyncEngineSetUpTests.swift | 10 +--- 6 files changed, 31 insertions(+), 78 deletions(-) delete mode 100644 Sources/SharingGRDBCore/CloudKit/SendChangesContext.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SendChangesContext.swift b/Sources/SharingGRDBCore/CloudKit/SendChangesContext.swift deleted file mode 100644 index f1b03dac..00000000 --- a/Sources/SharingGRDBCore/CloudKit/SendChangesContext.swift +++ /dev/null @@ -1,23 +0,0 @@ -#if canImport(CloudKit) - import CloudKit - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncEngine { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - package struct SendChangesContext: Sendable { - package var reason: CKSyncEngine.SyncReason - package var options: CKSyncEngine.SendChangesOptions - package init( - reason: CKSyncEngine.SyncReason = .scheduled, - options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all) - ) { - self.reason = reason - self.options = options - } - init(context: CKSyncEngine.SendChangesContext) { - reason = context.reason - options = context.options - } - } - } -#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 50fa55e0..79b76236 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -452,18 +452,18 @@ extension SyncEngine: CKSyncEngineDelegate { syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { await nextRecordZoneChangeBatch( - SendChangesContext(context: context), + reason: context.reason, + options: context.options, syncEngine: syncEngine ) } package func nextRecordZoneChangeBatch( - _ context: SendChangesContext, + reason: CKSyncEngine.SyncReason = .scheduled, + options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all), syncEngine: any SyncEngineProtocol ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let allChanges = syncEngine.state.pendingRecordZoneChanges.filter( - context.options.scope.contains - ) + let allChanges = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) guard !allChanges.isEmpty else { return nil } @@ -509,7 +509,7 @@ extension SyncEngine: CKSyncEngineDelegate { .joined(separator: ", ") logger.debug( """ - [\(syncEngine.database.databaseScope.label)] nextRecordZoneChangeBatch: \(context.reason) + [\(syncEngine.database.databaseScope.label)] nextRecordZoneChangeBatch: \(reason) \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index fb20b3cd..61991dab 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -53,9 +53,7 @@ extension BaseCloudKitTests { ] """ } - let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext(), syncEngine: privateSyncEngine - ) + let batch = await syncEngine.nextRecordZoneChangeBatch(syncEngine: privateSyncEngine) assertInlineSnapshot(of: batch, as: .customDump) { """ CKSyncEngine.RecordZoneChangeBatch( diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 12a22fc5..627ed7c7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -15,10 +15,8 @@ extension BaseCloudKitTests { ) let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([Reminder.recordID(for: UUID(1))]) - ) + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([Reminder.recordID(for: UUID(1))]) ), syncEngine: privateSyncEngine ) @@ -63,10 +61,8 @@ extension BaseCloudKitTests { } let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([UnrecognizedTable.recordID(for: UUID(1))]) - ) + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([UnrecognizedTable.recordID(for: UUID(1))]) ), syncEngine: privateSyncEngine ) @@ -111,10 +107,8 @@ extension BaseCloudKitTests { } let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) - ) + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) ), syncEngine: privateSyncEngine ) @@ -155,10 +149,8 @@ extension BaseCloudKitTests { } let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) - ) + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) ), syncEngine: privateSyncEngine ) @@ -225,13 +217,11 @@ extension BaseCloudKitTests { } let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([ - RemindersList.recordID(for: UUID(1)), - Reminder.recordID(for: UUID(1)), - ]) - ) + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([ + RemindersList.recordID(for: UUID(1)), + Reminder.recordID(for: UUID(1)), + ]) ), syncEngine: privateSyncEngine ) @@ -322,13 +312,11 @@ extension BaseCloudKitTests { } let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([ - RemindersList.recordID(for: UUID(1)), - Reminder.recordID(for: UUID(1)), - ]) - ) + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([ + RemindersList.recordID(for: UUID(1)), + Reminder.recordID(for: UUID(1)), + ]) ), syncEngine: privateSyncEngine ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 8e1ec8d6..41f15446 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -90,10 +90,8 @@ extension BaseCloudKitTests { } let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) - ) + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) ), syncEngine: sharedSyncEngine ) @@ -165,10 +163,8 @@ extension BaseCloudKitTests { } let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) - ) + options: CKSyncEngine.SendChangesOptions( + scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) ), syncEngine: sharedSyncEngine ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index efa0a4b3..da228dc9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -21,10 +21,7 @@ extension BaseCloudKitTests { reminder } } - _ = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext(), - syncEngine: privateSyncEngine - ) + _ = await syncEngine.nextRecordZoneChangeBatch(syncEngine: privateSyncEngine) let personalListRecord = CKRecord( recordType: RemindersList.tableName, @@ -69,10 +66,7 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - let batch = await syncEngine.nextRecordZoneChangeBatch( - SyncEngine.SendChangesContext(), - syncEngine: privateSyncEngine - ) + let batch = await syncEngine.nextRecordZoneChangeBatch(syncEngine: privateSyncEngine) #expect(batch == nil) let remindersLists = try await database.read { db in From a53c99d7c0130bf5f3d591ee357dfe776ec4b5ff Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 27 Jun 2025 11:01:10 -0700 Subject: [PATCH 245/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2106 +++++++++-------- 1 file changed, 1056 insertions(+), 1050 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 79b76236..45bf498c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1,883 +1,979 @@ #if canImport(CloudKit) -import CloudKit -import ConcurrencyExtras -import OSLog - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -public final class SyncEngine: Sendable { - public static nonisolated let defaultZone = CKRecordZone( - zoneName: "co.pointfree.SQLiteData.defaultZone" - ) - - // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates - @TaskLocal static var isUpdatingWithServerRecord = false - - let database: any DatabaseWriter - let logger: Logger - let metadatabase: any DatabaseReader - let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - let privateTables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] - let foreignKeysByTableName: [String: [ForeignKey]] - let syncEngines = LockIsolated(SyncEngines()) - let defaultSyncEngines: - @Sendable (any DatabaseReader, SyncEngine) - -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) - let container: any CloudContainer - - public convenience init( - container: CKContainer, - database: any DatabaseWriter, - logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] - ) throws { - try self.init( - container: container, - defaultSyncEngines: { metadatabase, syncEngine in - ( - private: CKSyncEngine( - CKSyncEngine.Configuration( - database: container.privateCloudDatabase, - stateSerialization: try? metadatabase.read { db in - try StateSerialization.find(CKDatabase.Scope.private).select(\.data).fetchOne(db) - }, - delegate: syncEngine - ) - ), - shared: CKSyncEngine( - CKSyncEngine.Configuration( - database: container.sharedCloudDatabase, - stateSerialization: try? metadatabase.read { db in - try StateSerialization.find(CKDatabase.Scope.shared).select(\.data).fetchOne(db) - }, - delegate: syncEngine - ) - ) - ) - }, - database: database, - logger: logger, - metadatabaseURL: URL.metadatabase(containerIdentifier: container.containerIdentifier), - tables: tables, - privateTables: privateTables - ) - _ = try setUpSyncEngine( - database: database, - metadatabase: metadatabase - ) - } + import CloudKit + import ConcurrencyExtras + import OSLog - package convenience init( - container: any CloudContainer, - privateSyncEngine: any SyncEngineProtocol, - sharedSyncEngine: any SyncEngineProtocol, - database: any DatabaseWriter, - metadatabaseURL: URL, - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] - ) async throws { - try self.init( - container: container, - defaultSyncEngines: { _, _ in (privateSyncEngine, sharedSyncEngine) }, - database: database, - logger: Logger(.disabled), - metadatabaseURL: metadatabaseURL, - tables: tables, - privateTables: privateTables + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public final class SyncEngine: Sendable { + public static nonisolated let defaultZone = CKRecordZone( + zoneName: "co.pointfree.SQLiteData.defaultZone" ) - try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value - } - private init( - container: any CloudContainer, - defaultSyncEngines: @escaping @Sendable ( - any DatabaseReader, - SyncEngine - ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), - database: any DatabaseWriter, - logger: Logger, - metadatabaseURL: URL, - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] - ) throws { - try validateSchema(tables: tables, database: database) - // TODO: Explain why / link to documentation? - precondition( - !database.configuration.foreignKeysEnabled, - """ - Foreign key support must be disabled to synchronize with CloudKit. - """ - ) - self.container = container - self.defaultSyncEngines = defaultSyncEngines - self.database = database - self.logger = logger - self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) - self.tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)).map(\.type) - self.privateTables = privateTables - self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) - self.foreignKeysByTableName = Dictionary( - uniqueKeysWithValues: try database.read { db in - try tables.map { table -> (String, [ForeignKey]) in + // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates + @TaskLocal static var isUpdatingWithServerRecord = false + + let database: any DatabaseWriter + let logger: Logger + let metadatabase: any DatabaseReader + let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + let privateTables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] + let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + let foreignKeysByTableName: [String: [ForeignKey]] + let syncEngines = LockIsolated(SyncEngines()) + let defaultSyncEngines: + @Sendable (any DatabaseReader, SyncEngine) + -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) + let container: any CloudContainer + + public convenience init( + container: CKContainer, + database: any DatabaseWriter, + logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] + ) throws { + try self.init( + container: container, + defaultSyncEngines: { metadatabase, syncEngine in ( - table.tableName, - try ForeignKey.all(table).fetchAll(db) + private: CKSyncEngine( + CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: try? metadatabase.read { db in + try StateSerialization.find(CKDatabase.Scope.private).select(\.data).fetchOne(db) + }, + delegate: syncEngine + ) + ), + shared: CKSyncEngine( + CKSyncEngine.Configuration( + database: container.sharedCloudDatabase, + stateSerialization: try? metadatabase.read { db in + try StateSerialization.find(CKDatabase.Scope.shared).select(\.data).fetchOne(db) + }, + delegate: syncEngine + ) + ) ) - } - } - ) - } - - package func setUpSyncEngine() async throws { - try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value - } - - nonisolated func setUpSyncEngine( - database: any DatabaseWriter, - metadatabase: any DatabaseReader - ) throws -> Task? { - try database.write { db in - let hasAttachedMetadatabase: Bool = - try SQLQueryExpression( - """ - SELECT count(*) - FROM pragma_database_list - WHERE "name" = \(bind: String.sqliteDataCloudKitSchemaName) - """, - as: Int.self + }, + database: database, + logger: logger, + metadatabaseURL: URL.metadatabase(containerIdentifier: container.containerIdentifier), + tables: tables, + privateTables: privateTables + ) + _ = try setUpSyncEngine( + database: database, + metadatabase: metadatabase ) - .fetchOne(db) == 1 - if !hasAttachedMetadatabase { - try SQLQueryExpression( - """ - ATTACH DATABASE \(bind: metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) - """ - ) - .execute(db) - } - db.add(function: .datetime) - db.add(function: .isUpdatingWithServerRecord) - db.add(function: .didUpdate(syncEngine: self)) - db.add(function: .didDelete(syncEngine: self)) - - for trigger in SyncMetadata.callbackTriggers { - try trigger.execute(db) - } - - for table in tables { - try table.createTriggers( - foreignKeysByTableName: foreignKeysByTableName, - tablesByName: tablesByName, - db: db - ) - } } - let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) - syncEngines.withValue { - $0 = SyncEngines( - private: privateSyncEngine, - shared: sharedSyncEngine + package convenience init( + container: any CloudContainer, + privateSyncEngine: any SyncEngineProtocol, + sharedSyncEngine: any SyncEngineProtocol, + database: any DatabaseWriter, + metadatabaseURL: URL, + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] + ) async throws { + try self.init( + container: container, + defaultSyncEngines: { _, _ in (privateSyncEngine, sharedSyncEngine) }, + database: database, + logger: Logger(.disabled), + metadatabaseURL: metadatabaseURL, + tables: tables, + privateTables: privateTables ) + try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value } - let previousRecordTypes = try metadatabase.read { db in - try RecordType.all.fetchAll(db) - } - let currentRecordTypes = try database.read { db in - try SQLQueryExpression( + + private init( + container: any CloudContainer, + defaultSyncEngines: @escaping @Sendable ( + any DatabaseReader, + SyncEngine + ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), + database: any DatabaseWriter, + logger: Logger, + metadatabaseURL: URL, + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] + ) throws { + try validateSchema(tables: tables, database: database) + // TODO: Explain why / link to documentation? + precondition( + !database.configuration.foreignKeysEnabled, + """ + Foreign key support must be disabled to synchronize with CloudKit. """ - SELECT "name", "sql" - FROM "sqlite_master" - WHERE "type" = 'table' - AND "name" IN (\(tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) - """, - as: RecordType.self ) - .fetchAll(db) - } - let recordTypesToFetch = currentRecordTypes.compactMap { currentRecordType in - guard - let existingRecordType = previousRecordTypes.first(where: { previousRecordType in - currentRecordType.tableName == previousRecordType.tableName - }) - else { return (currentRecordType, isNewTable: true) } - return existingRecordType.schema == currentRecordType.schema - ? nil - : (currentRecordType, isNewTable: false) + self.container = container + self.defaultSyncEngines = defaultSyncEngines + self.database = database + self.logger = logger + self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) + self.tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)).map( + \.type + ) + self.privateTables = privateTables + self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) + self.foreignKeysByTableName = Dictionary( + uniqueKeysWithValues: try database.read { db in + try tables.map { table -> (String, [ForeignKey]) in + ( + table.tableName, + try ForeignKey.all(table).fetchAll(db) + ) + } + } + ) } - guard !recordTypesToFetch.isEmpty - else { return nil } + package func setUpSyncEngine() async throws { + try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value + } - withErrorReporting(.sqliteDataCloudKitFailure) { + nonisolated func setUpSyncEngine( + database: any DatabaseWriter, + metadatabase: any DatabaseReader + ) throws -> Task? { try database.write { db in - for (recordType, isNewTable) in recordTypesToFetch { - try RecordType - .upsert { RecordType.Draft(recordType) } - .execute(db) - if isNewTable, let table = tablesByName[recordType.tableName] { - func open>(_: T.Type) throws { - try T - .update { $0.primaryKey = $0.primaryKey } - .execute(db) - } - try open(table) - } + let hasAttachedMetadatabase: Bool = + try SQLQueryExpression( + """ + SELECT count(*) + FROM pragma_database_list + WHERE "name" = \(bind: String.sqliteDataCloudKitSchemaName) + """, + as: Int.self + ) + .fetchOne(db) == 1 + if !hasAttachedMetadatabase { + try SQLQueryExpression( + """ + ATTACH DATABASE \(bind: metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) + """ + ) + .execute(db) + } + db.add(function: .datetime) + db.add(function: .isUpdatingWithServerRecord) + db.add(function: .didUpdate(syncEngine: self)) + db.add(function: .didDelete(syncEngine: self)) + + for trigger in SyncMetadata.callbackTriggers { + try trigger.execute(db) + } + + for table in tables { + try table.createTriggers( + foreignKeysByTableName: foreignKeysByTableName, + tablesByName: tablesByName, + db: db + ) } } - } - return Task { - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await fetchChangesFromSchemaChange( - recordTypesChanged: recordTypesToFetch.filter { !$0.isNewTable }.map(\.0) + let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) + syncEngines.withValue { + $0 = SyncEngines( + private: privateSyncEngine, + shared: sharedSyncEngine ) } - } - } + let previousRecordTypes = try metadatabase.read { db in + try RecordType.all.fetchAll(db) + } + let currentRecordTypes = try database.read { db in + try SQLQueryExpression( + """ + SELECT "name", "sql" + FROM "sqlite_master" + WHERE "type" = 'table' + AND "name" IN (\(tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) + """, + as: RecordType.self + ) + .fetchAll(db) + } + let recordTypesToFetch = currentRecordTypes.compactMap { currentRecordType in + guard + let existingRecordType = previousRecordTypes.first(where: { previousRecordType in + currentRecordType.tableName == previousRecordType.tableName + }) + else { return (currentRecordType, isNewTable: true) } + return existingRecordType.schema == currentRecordType.schema + ? nil + : (currentRecordType, isNewTable: false) + } - private func fetchChangesFromSchemaChange(recordTypesChanged: [RecordType]) async throws { - // TODO: do batches for sake of CKDatabase - // only docs we found was about modifies: https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation - // recommends limiting to <400 records and <2mb data posted - // TODO: Should we do this in batches now that we save the full 'lastKnowServerRecord'? - // TODO: Or should we denormalize zoneID into the metadata table for easy access? - let lastKnownServerRecords = try await metadatabase.read { db in - try SyncMetadata - .where { - $0.recordType.in(recordTypesChanged.map(\.tableName)) - && $0.lastKnownServerRecord.isNot(nil) + guard !recordTypesToFetch.isEmpty + else { return nil } + + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + for (recordType, isNewTable) in recordTypesToFetch { + try RecordType + .upsert { RecordType.Draft(recordType) } + .execute(db) + if isNewTable, let table = tablesByName[recordType.tableName] { + func open>(_: T.Type) throws { + try T + .update { $0.primaryKey = $0.primaryKey } + .execute(db) + } + try open(table) + } + } } - .select { - SQLQueryExpression( - "\($0.lastKnownServerRecord)", - as: CKRecord.DataRepresentation.self + } + + return Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await fetchChangesFromSchemaChange( + recordTypesChanged: recordTypesToFetch.filter { !$0.isNewTable }.map(\.0) ) } - .fetchAll(db) - } - let recordIDs = lastKnownServerRecords.map(\.recordID) - let recordIDsByDatabase = Dictionary(grouping: recordIDs) { - AnyCloudDatabase(container.database(for: $0)) + } } - for (database, recordIDs) in recordIDsByDatabase { - let results = try await database.records(for: recordIDs) - for (_, result) in results { - switch result { - case .success(let record): - upsertFromServerRecord(record) - break - case .failure(let error): - reportIssue(error) - break + + private func fetchChangesFromSchemaChange(recordTypesChanged: [RecordType]) async throws { + // TODO: do batches for sake of CKDatabase + // only docs we found was about modifies: https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation + // recommends limiting to <400 records and <2mb data posted + // TODO: Should we do this in batches now that we save the full 'lastKnowServerRecord'? + // TODO: Or should we denormalize zoneID into the metadata table for easy access? + let lastKnownServerRecords = try await metadatabase.read { db in + try SyncMetadata + .where { + $0.recordType.in(recordTypesChanged.map(\.tableName)) + && $0.lastKnownServerRecord.isNot(nil) + } + .select { + SQLQueryExpression( + "\($0.lastKnownServerRecord)", + as: CKRecord.DataRepresentation.self + ) + } + .fetchAll(db) + } + let recordIDs = lastKnownServerRecords.map(\.recordID) + let recordIDsByDatabase = Dictionary(grouping: recordIDs) { + AnyCloudDatabase(container.database(for: $0)) + } + for (database, recordIDs) in recordIDsByDatabase { + let results = try await database.records(for: recordIDs) + for (_, result) in results { + switch result { + case .success(let record): + upsertFromServerRecord(record) + break + case .failure(let error): + reportIssue(error) + break + } } } } - } - package func tearDownSyncEngine() async throws { - let syncEngines = syncEngines.withValue(\.self) - async let privateCancellation: Void? = syncEngines.private?.cancelOperations() - async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() + package func tearDownSyncEngine() async throws { + let syncEngines = syncEngines.withValue(\.self) + async let privateCancellation: Void? = syncEngines.private?.cancelOperations() + async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() - try await database.asyncWrite { db in - for table in self.tables { - try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) + try await database.asyncWrite { db in + for table in self.tables { + try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) + } + for trigger in SyncMetadata.callbackTriggers.reversed() { + try trigger.drop().execute(db) + } + db.remove(function: .didDelete(syncEngine: self)) + db.remove(function: .didUpdate(syncEngine: self)) + db.remove(function: .isUpdatingWithServerRecord) + db.remove(function: .datetime) } - for trigger in SyncMetadata.callbackTriggers.reversed() { - try trigger.drop().execute(db) + try await database.asyncWrite { db in + // TODO: Do an `.erase()` + re-migrate + try SyncMetadata.delete().execute(db) + try RecordType.delete().execute(db) + try StateSerialization.delete().execute(db) } - db.remove(function: .didDelete(syncEngine: self)) - db.remove(function: .didUpdate(syncEngine: self)) - db.remove(function: .isUpdatingWithServerRecord) - db.remove(function: .datetime) + _ = await (privateCancellation, sharedCancellation) } - try await database.asyncWrite { db in - // TODO: Do an `.erase()` + re-migrate - try SyncMetadata.delete().execute(db) - try RecordType.delete().execute(db) - try StateSerialization.delete().execute(db) - } - _ = await (privateCancellation, sharedCancellation) - } - #if DEBUG - public func deleteLocalData() async throws { - try await tearDownSyncEngine() - withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in - for table in tables { - func open(_: T.Type) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try T.delete().execute(db) + #if DEBUG + public func deleteLocalData() async throws { + try await tearDownSyncEngine() + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + for table in tables { + func open(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try T.delete().execute(db) + } + } + open(table) } } - open(table) } + try await setUpSyncEngine() } - } - try await setUpSyncEngine() - } - #endif + #endif - func didUpdate(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { - let zoneID = zoneID ?? Self.defaultZone.zoneID - let syncEngine = self.syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared - } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: recordName.rawValue, - zoneID: zoneID + func didUpdate(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { + let zoneID = zoneID ?? Self.defaultZone.zoneID + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + syncEngine?.state.add( + pendingRecordZoneChanges: [ + .saveRecord( + CKRecord.ID( + recordName: recordName.rawValue, + zoneID: zoneID + ) ) - ) - ] - ) - } - - func didDelete(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { - let zoneID = zoneID ?? Self.defaultZone.zoneID - let syncEngine = self.syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + ] + ) } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .deleteRecord( - CKRecord.ID( - recordName: recordName.rawValue, - zoneID: zoneID + + func didDelete(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { + let zoneID = zoneID ?? Self.defaultZone.zoneID + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + syncEngine?.state.add( + pendingRecordZoneChanges: [ + .deleteRecord( + CKRecord.ID( + recordName: recordName.rawValue, + zoneID: zoneID + ) ) - ) - ] - ) + ] + ) + } } -} -extension PrimaryKeyedTable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func createTriggers( - foreignKeysByTableName: [String: [ForeignKey]], - tablesByName: [String: any PrimaryKeyedTable.Type], - db: Database - ) throws { - let parentForeignKey = - foreignKeysByTableName[tableName]?.count == 1 - ? foreignKeysByTableName[tableName]?.first - : nil + extension PrimaryKeyedTable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func createTriggers( + foreignKeysByTableName: [String: [ForeignKey]], + tablesByName: [String: any PrimaryKeyedTable.Type], + db: Database + ) throws { + let parentForeignKey = + foreignKeysByTableName[tableName]?.count == 1 + ? foreignKeysByTableName[tableName]?.first + : nil + + for trigger in metadataTriggers(parentForeignKey: parentForeignKey) { + try trigger.execute(db) + } - for trigger in metadataTriggers(parentForeignKey: parentForeignKey) { - try trigger.execute(db) + let foreignKeys = foreignKeysByTableName[tableName] ?? [] + for foreignKey in foreignKeys { + guard let parent = tablesByName[foreignKey.table] else { + reportIssue("TODO") + continue + } + try foreignKey.createTriggers(Self.self, belongsTo: parent, db: db) + } } - let foreignKeys = foreignKeysByTableName[tableName] ?? [] - for foreignKey in foreignKeys { - guard let parent = tablesByName[foreignKey.table] else { - reportIssue("TODO") - continue + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func dropTriggers( + foreignKeysByTableName: [String: [ForeignKey]], + db: Database + ) throws { + let foreignKeys = foreignKeysByTableName[tableName] ?? [] + for foreignKey in foreignKeys.reversed() { + try foreignKey.dropTriggers(for: Self.self, db: db) + } + + for trigger in metadataTriggers(parentForeignKey: nil).reversed() { + try trigger.drop().execute(db) } - try foreignKey.createTriggers(Self.self, belongsTo: parent, db: db) } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func dropTriggers( - foreignKeysByTableName: [String: [ForeignKey]], - db: Database - ) throws { - let foreignKeys = foreignKeysByTableName[tableName] ?? [] - for foreignKey in foreignKeys.reversed() { - try foreignKey.dropTriggers(for: Self.self, db: db) + extension SyncEngine: CKSyncEngineDelegate { + public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + guard let event = Event(event) + else { + reportIssue("Unrecognized event received: \(event)") + return + } + await handleEvent(event, syncEngine: syncEngine) } - for trigger in metadataTriggers(parentForeignKey: nil).reversed() { - try trigger.drop().execute(db) - } - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine: CKSyncEngineDelegate { - public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - guard let event = Event(event) - else { - reportIssue("Unrecognized event received: \(event)") - return + package func handleEvent(_ event: Event, syncEngine: any SyncEngineProtocol) async { + logger.log(event, syncEngine: syncEngine) + + switch event { + case .accountChange(let changeType): + await handleAccountChange(changeType: changeType) + case .stateUpdate(let stateSerialization): + handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) + case .fetchedDatabaseChanges(let modifications, let deletions): + handleFetchedDatabaseChanges(modifications: modifications, deletions: deletions) + case .sentDatabaseChanges: + break + case .fetchedRecordZoneChanges(let modifications, let deletions): + await handleFetchedRecordZoneChanges( + modifications: modifications, + deletions: deletions + ) + case .sentRecordZoneChanges( + let savedRecords, + let failedRecordSaves, + let deletedRecordIDs, + let failedRecordDeletes + ): + handleSentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes, + syncEngine: syncEngine + ) + case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, + .didFetchChanges, .willSendChanges, .didSendChanges: + break + @unknown default: + break + } } - await handleEvent(event, syncEngine: syncEngine) - } - package func handleEvent(_ event: Event, syncEngine: any SyncEngineProtocol) async { - logger.log(event, syncEngine: syncEngine) - - switch event { - case .accountChange(let changeType): - await handleAccountChange(changeType: changeType) - case .stateUpdate(let stateSerialization): - handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) - case .fetchedDatabaseChanges(let modifications, let deletions): - handleFetchedDatabaseChanges(modifications: modifications, deletions: deletions) - case .sentDatabaseChanges: - break - case .fetchedRecordZoneChanges(let modifications, let deletions): - await handleFetchedRecordZoneChanges( - modifications: modifications, - deletions: deletions - ) - case .sentRecordZoneChanges( - let savedRecords, - let failedRecordSaves, - let deletedRecordIDs, - let failedRecordDeletes - ): - handleSentRecordZoneChanges( - savedRecords: savedRecords, - failedRecordSaves: failedRecordSaves, - deletedRecordIDs: deletedRecordIDs, - failedRecordDeletes: failedRecordDeletes, + public func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + await nextRecordZoneChangeBatch( + reason: context.reason, + options: context.options, syncEngine: syncEngine ) - case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, - .didFetchChanges, .willSendChanges, .didSendChanges: - break - @unknown default: - break } - } - - public func nextRecordZoneChangeBatch( - _ context: CKSyncEngine.SendChangesContext, - syncEngine: CKSyncEngine - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - await nextRecordZoneChangeBatch( - reason: context.reason, - options: context.options, - syncEngine: syncEngine - ) - } - package func nextRecordZoneChangeBatch( - reason: CKSyncEngine.SyncReason = .scheduled, - options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all), - syncEngine: any SyncEngineProtocol - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let allChanges = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) - guard !allChanges.isEmpty - else { return nil } - - var allChangesByIsDeleted = Dictionary(grouping: allChanges) { - switch $0 { - case .deleteRecord: true - case .saveRecord: false - @unknown default: false + package func nextRecordZoneChangeBatch( + reason: CKSyncEngine.SyncReason = .scheduled, + options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all), + syncEngine: any SyncEngineProtocol + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + let allChanges = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) + guard !allChanges.isEmpty + else { return nil } + + var allChangesByIsDeleted = Dictionary(grouping: allChanges) { + switch $0 { + case .deleteRecord: true + case .saveRecord: false + @unknown default: false + } } - } - // TODO: why did we do this again? can we test it? - allChangesByIsDeleted[true]?.reverse() - let changes = allChangesByIsDeleted.reduce(into: []) { changes, keyValue in - changes += keyValue.value - } - - #if DEBUG - struct State { - var missingTables: [CKRecord.ID] = [] - var missingRecords: [CKRecord.ID] = [] - var sentRecords: [CKRecord.ID] = [] + // TODO: why did we do this again? can we test it? + allChangesByIsDeleted[true]?.reverse() + let changes = allChangesByIsDeleted.reduce(into: []) { changes, keyValue in + changes += keyValue.value } - let state = LockIsolated(State()) - defer { - let state = state.withValue(\.self) - let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) - .reduce(into: [String]()) { - strings, - keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] - } - .joined(separator: ", ") - logger.debug( - """ - [\(syncEngine.database.databaseScope.label)] nextRecordZoneChangeBatch: \(reason) - \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") - \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") - \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") - """ - ) - } - #endif - let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in #if DEBUG - var missingTable: CKRecord.ID? - var missingRecord: CKRecord.ID? - var sentRecord: CKRecord.ID? + struct State { + var missingTables: [CKRecord.ID] = [] + var missingRecords: [CKRecord.ID] = [] + var sentRecords: [CKRecord.ID] = [] + } + let state = LockIsolated(State()) defer { - state.withValue { [missingTable, missingRecord, sentRecord] in - if let missingTable { $0.missingTables.append(missingTable) } - if let missingRecord { $0.missingRecords.append(missingRecord) } - if let sentRecord { $0.sentRecords.append(sentRecord) } - } + let state = state.withValue(\.self) + let missingTables = Dictionary(grouping: state.missingTables, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let missingRecords = Dictionary(grouping: state.missingRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + let sentRecords = Dictionary(grouping: state.sentRecords, by: \.zoneID.zoneName) + .reduce(into: [String]()) { + strings, + keyValue in strings += ["\(keyValue.key) (\(keyValue.value.count))"] + } + .joined(separator: ", ") + logger.debug( + """ + [\(syncEngine.database.databaseScope.label)] nextRecordZoneChangeBatch: \(reason) + \(state.missingTables.isEmpty ? "⚪️ No missing tables" : "⚠️ Missing tables: \(missingTables)") + \(state.missingRecords.isEmpty ? "⚪️ No missing records" : "⚠️ Missing records: \(missingRecords)") + \(state.sentRecords.isEmpty ? "⚪️ No sent records" : "✅ Sent records: \(sentRecords)") + """ + ) } #endif - guard - let recordName = SyncMetadata.RecordName(recordID: recordID), - let metadata = metadataFor(recordName: recordName) - else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) - return nil - } - guard let table = tablesByName[metadata.recordType] - else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) - missingTable = recordID - return nil - } - func open>(_: T.Type) async -> CKRecord? { - let row = - withErrorReporting { - try database.read { db in - try T.find(recordName.id).fetchOne(db) + let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in + #if DEBUG + var missingTable: CKRecord.ID? + var missingRecord: CKRecord.ID? + var sentRecord: CKRecord.ID? + defer { + state.withValue { [missingTable, missingRecord, sentRecord] in + if let missingTable { $0.missingTables.append(missingTable) } + if let missingRecord { $0.missingRecords.append(missingRecord) } + if let sentRecord { $0.sentRecords.append(sentRecord) } } } - ?? nil - guard let row + #endif + + guard + let recordName = SyncMetadata.RecordName(recordID: recordID), + let metadata = metadataFor(recordName: recordName) else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) - missingRecord = recordID return nil } + guard let table = tablesByName[metadata.recordType] + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingTable = recordID + return nil + } + func open>(_: T.Type) async -> CKRecord? { + let row = + withErrorReporting { + try database.read { db in + try T.find(recordName.id).fetchOne(db) + } + } + ?? nil + guard let row + else { + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + missingRecord = recordID + return nil + } - let record = - metadata.lastKnownServerRecord - ?? CKRecord( - recordType: metadata.recordType, - recordID: recordID - ) - record.parent = metadata.parentRecordName.flatMap { parentRecordName in - guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) - else { return nil } - return CKRecord.Reference( - recordID: CKRecord.ID( - recordName: parentRecordName.rawValue, - zoneID: record.recordID.zoneID - ), - action: .none + let record = + metadata.lastKnownServerRecord + ?? CKRecord( + recordType: metadata.recordType, + recordID: recordID + ) + record.parent = metadata.parentRecordName.flatMap { parentRecordName in + guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) + else { return nil } + return CKRecord.Reference( + recordID: CKRecord.ID( + recordName: parentRecordName.rawValue, + zoneID: record.recordID.zoneID + ), + action: .none + ) + } + record.update( + with: T(queryOutput: row), + userModificationDate: metadata.userModificationDate ) + refreshLastKnownServerRecord(record) + sentRecord = recordID + return record } - record.update( - with: T(queryOutput: row), - userModificationDate: metadata.userModificationDate - ) - refreshLastKnownServerRecord(record) - sentRecord = recordID - return record + return await open(table) } - return await open(table) + return batch } - return batch - } - package func handleAccountChange(changeType: CKSyncEngine.Event.AccountChange.ChangeType) async { - switch changeType { - case .signIn: - syncEngines.withValue { - $0.private?.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) - } - for table in tables { - withErrorReporting(.sqliteDataCloudKitFailure) { - let recordNames = try database.read { db in - func open>(_: T.Type) throws -> [SyncMetadata.RecordName] { - try T - .select(\.primaryKey) - .fetchAll(db) - .map { T.recordName(for: $0) } + package func handleAccountChange(changeType: CKSyncEngine.Event.AccountChange.ChangeType) async + { + switch changeType { + case .signIn: + syncEngines.withValue { + $0.private?.state.add(pendingDatabaseChanges: [.saveZone(Self.defaultZone)]) + } + for table in tables { + withErrorReporting(.sqliteDataCloudKitFailure) { + let recordNames = try database.read { db in + func open>(_: T.Type) throws -> [SyncMetadata.RecordName] { + try T + .select(\.primaryKey) + .fetchAll(db) + .map { T.recordName(for: $0) } + } + return try open(table) } - return try open(table) - } - syncEngines.withValue { - $0.private?.state.add( - pendingRecordZoneChanges: recordNames.map { - .saveRecord( - CKRecord.ID( - recordName: $0.rawValue, - zoneID: Self.defaultZone.zoneID + syncEngines.withValue { + $0.private?.state.add( + pendingRecordZoneChanges: recordNames.map { + .saveRecord( + CKRecord.ID( + recordName: $0.rawValue, + zoneID: Self.defaultZone.zoneID + ) ) - ) - } - ) + } + ) + } } } - } - case .signOut, .switchAccounts: - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await deleteLocalData() - } - @unknown default: - break - } - } - - package func handleStateUpdate( - stateSerialization: CKSyncEngine.State.Serialization, - syncEngine: any SyncEngineProtocol - ) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in - try StateSerialization.upsert { - StateSerialization.Draft( - scope: syncEngine.database.databaseScope, - data: stateSerialization - ) + case .signOut, .switchAccounts: + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await deleteLocalData() } - .execute(db) + @unknown default: + break } } - } - package func handleFetchedDatabaseChanges( - modifications: [CKRecordZone.ID], - deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)] - ) { - // TODO: How to handle this? - Self.$isUpdatingWithServerRecord.withValue(true) { + package func handleStateUpdate( + stateSerialization: CKSyncEngine.State.Serialization, + syncEngine: any SyncEngineProtocol + ) { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in - for deletion in deletions { - // if let table = tablesByName[deletion.zoneID.zoneName] { - // func open(_: T.Type) { - // withErrorReporting(.sqliteDataCloudKitFailure) { - // try T.delete().execute(db) - // } - // } - // open(table) + try StateSerialization.upsert { + StateSerialization.Draft( + scope: syncEngine.database.databaseScope, + data: stateSerialization + ) } + .execute(db) } - - // TODO: Deal with modifications? - _ = modifications } } - } - package func handleFetchedRecordZoneChanges( - modifications: [CKRecord] = [], - deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [] - ) async { - await Self.$isUpdatingWithServerRecord.withValue(true) { - for record in modifications { - if let share = record as? CKShare { - await withErrorReporting { - try await cacheShare(share) + package func handleFetchedDatabaseChanges( + modifications: [CKRecordZone.ID], + deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)] + ) { + // TODO: How to handle this? + Self.$isUpdatingWithServerRecord.withValue(true) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + for deletion in deletions { + // if let table = tablesByName[deletion.zoneID.zoneName] { + // func open(_: T.Type) { + // withErrorReporting(.sqliteDataCloudKitFailure) { + // try T.delete().execute(db) + // } + // } + // open(table) + } } - } else { - upsertFromServerRecord(record) - refreshLastKnownServerRecord(record) + + // TODO: Deal with modifications? + _ = modifications } - if let shareReference = record.share, - // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in - let shareRecord = try? await container.database(for: shareReference.recordID) - .record(for: shareReference.recordID), - let share = shareRecord as? CKShare - { - await withErrorReporting { - try await cacheShare(share) + } + } + + package func handleFetchedRecordZoneChanges( + modifications: [CKRecord] = [], + deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [] + ) async { + await Self.$isUpdatingWithServerRecord.withValue(true) { + for record in modifications { + if let share = record as? CKShare { + await withErrorReporting { + try await cacheShare(share) + } + } else { + upsertFromServerRecord(record) + refreshLastKnownServerRecord(record) + } + if let shareReference = record.share, + // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in + let shareRecord = try? await container.database(for: shareReference.recordID) + .record(for: shareReference.recordID), + let share = shareRecord as? CKShare + { + await withErrorReporting { + try await cacheShare(share) + } } } - } - for (recordID, recordType) in deletions { - if let table = tablesByName[recordType] { - guard let recordName = SyncMetadata.RecordName(recordID: recordID) - else { - reportIssue( - """ - Received 'recordName' in invalid format: \(recordID.recordName) + for (recordID, recordType) in deletions { + if let table = tablesByName[recordType] { + guard let recordName = SyncMetadata.RecordName(recordID: recordID) + else { + reportIssue( + """ + Received 'recordName' in invalid format: \(recordID.recordName) - 'recordName' should be formatted as "uuid:tableName". - """ + 'recordName' should be formatted as "uuid:tableName". + """ + ) + continue + } + func open>(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + try T.find(recordName.id) + .delete() + .execute(db) + } + } + } + open(table) + } else if recordType == CKRecord.SystemType.share { + withErrorReporting { + try deleteShare(recordID: recordID, recordType: recordType) + } + } else { + // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? + reportIssue( + .sqliteDataCloudKitFailure.appending( + """ + : No table to delete from: "\(recordType)" + """ + ) ) - continue } - func open>(_: T.Type) { - withErrorReporting(.sqliteDataCloudKitFailure) { + } + } + } + + package func handleSentRecordZoneChanges( + savedRecords: [CKRecord] = [], + failedRecordSaves: [(record: CKRecord, error: CKError)] = [], + deletedRecordIDs: [CKRecord.ID] = [], + failedRecordDeletes: [CKRecord.ID: CKError] = [:], + syncEngine: any SyncEngineProtocol + ) { + for savedRecord in savedRecords { + refreshLastKnownServerRecord(savedRecord) + } + + var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] + var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] + defer { + syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + } + for failedRecordSave in failedRecordSaves { + let failedRecord = failedRecordSave.record + guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) + else { + reportIssue( + """ + Attempted to delete record with invalid 'recordName': \(failedRecord.recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) + continue + } + + func clearServerRecord() { + withErrorReporting { + try Self.$isUpdatingWithServerRecord.withValue(true) { try database.write { db in - try T.find(recordName.id) - .delete() + try SyncMetadata + .find(recordName) + .update { $0.lastKnownServerRecord = nil } .execute(db) } } } - open(table) - } else if recordType == CKRecord.SystemType.share { - withErrorReporting { - try deleteShare(recordID: recordID, recordType: recordType) - } - } else { - // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? - reportIssue( - .sqliteDataCloudKitFailure.appending( - """ - : No table to delete from: "\(recordType)" - """ - ) - ) + } + + switch failedRecordSave.error.code { + case .serverRecordChanged: + guard let serverRecord = failedRecordSave.error.serverRecord else { continue } + upsertFromServerRecord(serverRecord) + refreshLastKnownServerRecord(serverRecord) + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + + case .zoneNotFound: + let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) + // TODO: handle this + //newPendingDatabaseChanges.append(.saveZone(zone)) + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + clearServerRecord() + + case .unknownItem: + newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) + clearServerRecord() + + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, + .notAuthenticated, + .operationCancelled, .batchRequestFailed: + continue + + default: + continue } } + // TODO: handle event.failedRecordDeletes ? look at apple sample code } - } - package func handleSentRecordZoneChanges( - savedRecords: [CKRecord] = [], - failedRecordSaves: [(record: CKRecord, error: CKError)] = [], - deletedRecordIDs: [CKRecord.ID] = [], - failedRecordDeletes: [CKRecord.ID: CKError] = [:], - syncEngine: any SyncEngineProtocol - ) { - for savedRecord in savedRecords { - refreshLastKnownServerRecord(savedRecord) - } + private func cacheShare(_ share: CKShare) async throws { + guard let url = share.url + else { return } - var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] - var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] - defer { - syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) - syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) - } - for failedRecordSave in failedRecordSaves { - let failedRecord = failedRecordSave.record - guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) + guard + let metadata = try? await container.shareMetadata( + for: url, + shouldFetchRootRecord: true + ) + else { + // TODO: should we delete this record if it doesn't exist in the container? + return + } + + guard let rootRecord = metadata.rootRecord + else { return } + guard let recordName = SyncMetadata.RecordName(recordID: rootRecord.recordID) else { reportIssue( """ - Attempted to delete record with invalid 'recordName': \(failedRecord.recordID.recordName) + Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) 'recordName' should be formatted as "uuid:tableName". """ ) - continue - } - - func clearServerRecord() { - withErrorReporting { - try Self.$isUpdatingWithServerRecord.withValue(true) { - try database.write { db in - try SyncMetadata - .find(recordName) - .update { $0.lastKnownServerRecord = nil } - .execute(db) - } - } - } + return } - switch failedRecordSave.error.code { - case .serverRecordChanged: - guard let serverRecord = failedRecordSave.error.serverRecord else { continue } - upsertFromServerRecord(serverRecord) - refreshLastKnownServerRecord(serverRecord) - newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - - case .zoneNotFound: - let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) - // TODO: handle this - //newPendingDatabaseChanges.append(.saveZone(zone)) - newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - clearServerRecord() - - case .unknownItem: - newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - clearServerRecord() - - case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, - .operationCancelled, .batchRequestFailed: - continue - - default: - continue + try await database.asyncWrite { db in + try SyncMetadata + .find(recordName) + .update { $0.share = share } + .execute(db) } } - // TODO: handle event.failedRecordDeletes ? look at apple sample code - } - - private func cacheShare(_ share: CKShare) async throws { - guard let url = share.url - else { return } - - guard let metadata = try? await container.shareMetadata( - for: url, - shouldFetchRootRecord: true - ) - else { - // TODO: should we delete this record if it doesn't exist in the container? - return - } - - guard let rootRecord = metadata.rootRecord - else { return } - guard let recordName = SyncMetadata.RecordName(recordID: rootRecord.recordID) - else { - reportIssue( - """ - Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) - return - } - - try await database.asyncWrite { db in - try SyncMetadata - .find(recordName) - .update { $0.share = share } - .execute(db) - } - } - private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { - // TODO: more efficient way to do this? - try database.write { db in - let metadata = - try SyncMetadata - .where { $0.share.isNot(nil) } - .fetchAll(db) - .first(where: { $0.share?.recordID == recordID }) ?? nil - guard let metadata - else { return } - try SyncMetadata.find(metadata.recordName) - .update { $0.share = nil } - .execute(db) + private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { + // TODO: more efficient way to do this? + try database.write { db in + let metadata = + try SyncMetadata + .where { $0.share.isNot(nil) } + .fetchAll(db) + .first(where: { $0.share?.recordID == recordID }) ?? nil + guard let metadata + else { return } + try SyncMetadata.find(metadata.recordName) + .update { $0.share = nil } + .execute(db) + } } - } - private func upsertFromServerRecord(_ record: CKRecord) { - Self.$isUpdatingWithServerRecord.withValue(true) { - withErrorReporting(.sqliteDataCloudKitFailure) { - guard let table = tablesByName[record.recordType] - else { - // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? - reportIssue( - .sqliteDataCloudKitFailure.appending( + private func upsertFromServerRecord(_ record: CKRecord) { + Self.$isUpdatingWithServerRecord.withValue(true) { + withErrorReporting(.sqliteDataCloudKitFailure) { + guard let table = tablesByName[record.recordType] + else { + // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? + reportIssue( + .sqliteDataCloudKitFailure.appending( + """ + : No table to merge from: "\(record.recordType)" + """ + ) + ) + return + } + guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) + else { + reportIssue( """ - : No table to merge from: "\(record.recordType)" + Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) + + 'recordName' should be formatted as "uuid:tableName". """ ) - ) - return + return + } + let userModificationDate = + try metadatabase.read { db in + try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( + db + ) + } + ?? nil + guard + let userModificationDate, + userModificationDate > record.userModificationDate ?? .distantPast + else { + // TODO: This should be fetched early and held onto (like 'ForeignKey') + let columnNames = try database.read { db in + try SQLQueryExpression( + """ + SELECT "name" + FROM pragma_table_info(\(bind: table.tableName)) + """, + as: String.self + ) + .fetchAll(db) + } + var query: QueryFragment = "INSERT INTO \(table) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + let encryptedValues = record.encryptedValues + query.append( + columnNames + .map { columnName in + if let asset = record[columnName] as? CKAsset { + return (try? asset.fileURL.map { try Data(contentsOf: $0) })? + .queryFragment ?? "NULL" + } else { + return encryptedValues[columnName]?.queryFragment ?? "NULL" + } + } + .joined(separator: ", ") + ) + func open(_: T.Type) -> String { + T.columns.primaryKey.name + } + let primaryKeyName = open(table) + query.append(") ON CONFLICT(\(quote: primaryKeyName)) DO UPDATE SET ") + + query.append( + columnNames + .filter { columnName in columnName != primaryKeyName } + .map { + """ + \(quote: $0) = "excluded".\(quote: $0) + """ + } + .joined(separator: ",") + ) + // TODO: Append more ON CONFLICT clauses for each unique constraint? + // TODO: Use WHERE to scope the update? + guard let metadata = SyncMetadata(record: record) + else { + reportIssue("???") + return + } + try database.write { db in + try SQLQueryExpression(query).execute(db) + try SyncMetadata + .insert { + metadata + } onConflictDoUpdate: { + $0.lastKnownServerRecord = record + $0.userModificationDate = record.userModificationDate + } + .execute(db) + } + return + } } + } + } + + private func refreshLastKnownServerRecord(_ record: CKRecord) { + Self.$isUpdatingWithServerRecord.withValue(true) { guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) else { reportIssue( @@ -889,354 +985,264 @@ extension SyncEngine: CKSyncEngineDelegate { ) return } - let userModificationDate = - try metadatabase.read { db in - try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( - db - ) - } - ?? nil - guard - let userModificationDate, - userModificationDate > record.userModificationDate ?? .distantPast - else { - // TODO: This should be fetched early and held onto (like 'ForeignKey') - let columnNames = try database.read { db in - try SQLQueryExpression( - """ - SELECT "name" - FROM pragma_table_info(\(bind: table.tableName)) - """, - as: String.self - ) - .fetchAll(db) - } - var query: QueryFragment = "INSERT INTO \(table) (" - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) - query.append(") VALUES (") - let encryptedValues = record.encryptedValues - query.append( - columnNames - .map { columnName in - if let asset = record[columnName] as? CKAsset { - return (try? asset.fileURL.map { try Data(contentsOf: $0) })? - .queryFragment ?? "NULL" - } else { - return encryptedValues[columnName]?.queryFragment ?? "NULL" - } - } - .joined(separator: ", ") - ) - func open(_: T.Type) -> String { - T.columns.primaryKey.name - } - let primaryKeyName = open(table) - query.append(") ON CONFLICT(\(quote: primaryKeyName)) DO UPDATE SET ") + let metadata = metadataFor(recordName: recordName) - query.append( - columnNames - .filter { columnName in columnName != primaryKeyName } - .map { - """ - \(quote: $0) = "excluded".\(quote: $0) - """ - } - .joined(separator: ",") - ) - // TODO: Append more ON CONFLICT clauses for each unique constraint? - // TODO: Use WHERE to scope the update? - guard let metadata = SyncMetadata(record: record) - else { - reportIssue("???") - return + func updateLastKnownServerRecord() { + withErrorReporting(.sqliteDataCloudKitFailure) { + try database.write { db in + try SyncMetadata + .find(recordName) + .update { $0.lastKnownServerRecord = record } + .execute(db) + } } - try database.write { db in - try SQLQueryExpression(query).execute(db) - try SyncMetadata - .insert { - metadata - } onConflictDoUpdate: { - $0.lastKnownServerRecord = record - $0.userModificationDate = record.userModificationDate - } - .execute(db) + } + + if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { + if let recordDate = record.modificationDate, lastKnownDate < recordDate { + updateLastKnownServerRecord() } - return + } else { + updateLastKnownServerRecord() } } } - } - private func refreshLastKnownServerRecord(_ record: CKRecord) { - Self.$isUpdatingWithServerRecord.withValue(true) { - guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) - else { - reportIssue( - """ - Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) + private func metadataFor(recordName: SyncMetadata.RecordName) -> SyncMetadata? { + withErrorReporting(.sqliteDataCloudKitFailure) { + try metadatabase.read { db in + try SyncMetadata.find(recordName).fetchOne(db) + } + } + ?? nil + } + } - 'recordName' should be formatted as "uuid:tableName". - """ + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension DatabaseFunction { + fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { + Self("didUpdate") { recordName, zoneID in + syncEngine.didUpdate( + recordName: recordName, + zoneID: zoneID ) - return } - let metadata = metadataFor(recordName: recordName) + } - func updateLastKnownServerRecord() { - withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in - try SyncMetadata - .find(recordName) - .update { $0.lastKnownServerRecord = record } - .execute(db) - } - } + fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { + return Self("didDelete") { recordName, zoneID in + syncEngine.didDelete(recordName: recordName, zoneID: zoneID) } + } - if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { - if let recordDate = record.modificationDate, lastKnownDate < recordDate { - updateLastKnownServerRecord() - } - } else { - updateLastKnownServerRecord() + fileprivate static var datetime: Self { + Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in + @Dependency(\.date.now) var now + return now.formatted( + .iso8601 + .year().month().day() + .dateTimeSeparator(.space) + .time(includingFractionalSeconds: true) + ) } } - } - private func metadataFor(recordName: SyncMetadata.RecordName) -> SyncMetadata? { - withErrorReporting(.sqliteDataCloudKitFailure) { - try metadatabase.read { db in - try SyncMetadata.find(recordName).fetchOne(db) + fileprivate static var isUpdatingWithServerRecord: Self { + Self(.sqliteDataCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { + _ in + SyncEngine.isUpdatingWithServerRecord } } - ?? nil - } -} - -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension DatabaseFunction { - fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { recordName, zoneID in - syncEngine.didUpdate( - recordName: recordName, - zoneID: zoneID - ) + + private convenience init( + _ name: String, + function: @escaping @Sendable (SyncMetadata.RecordName, CKRecordZone.ID?) -> Void + ) { + self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 2) { arguments in + guard + let recordName = String.fromDatabaseValue(arguments[0]) + else { + return nil + } + guard let recordName = SyncMetadata.RecordName(rawValue: recordName) + else { + reportIssue( + """ + Received 'recordName' in invalid format: \(recordName) + + 'recordName' should be formatted as "uuid:tableName". + """ + ) + return nil + } + let zoneID = try Data.fromDatabaseValue(arguments[1]).flatMap { + let coder = try NSKeyedUnarchiver(forReadingFrom: $0) + coder.requiresSecureCoding = true + return CKRecord(coder: coder)?.recordID.zoneID + } + function(recordName, zoneID) + return nil + } } } - fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { - return Self("didDelete") { recordName, zoneID in - syncEngine.didDelete(recordName: recordName, zoneID: zoneID) - } + extension String { + package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" + fileprivate static let sqliteDataCloudKitFailure = "SharingGRDB CloudKit Failure" } - fileprivate static var datetime: Self { - Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in - @Dependency(\.date.now) var now - return now.formatted( - .iso8601 - .year().month().day() - .dateTimeSeparator(.space) - .time(includingFractionalSeconds: true) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension URL { + package static func metadatabase(containerIdentifier: String?) throws -> Self { + @Dependency(\.context) var context + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + let base: URL = + context == .live + ? .applicationSupportDirectory + : .temporaryDirectory + return base.appending( + component: "\(containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" ) } } - fileprivate static var isUpdatingWithServerRecord: Self { - Self(.sqliteDataCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { - _ in - SyncEngine.isUpdatingWithServerRecord + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + struct SyncEngines { + let _private: (any SyncEngineProtocol)? + let _shared: (any SyncEngineProtocol)? + init() { + _private = nil + _shared = nil } - } - - private convenience init( - _ name: String, - function: @escaping @Sendable (SyncMetadata.RecordName, CKRecordZone.ID?) -> Void - ) { - self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 2) { arguments in - guard - let recordName = String.fromDatabaseValue(arguments[0]) + init(private: any SyncEngineProtocol, shared: any SyncEngineProtocol) { + self._private = `private` + self._shared = shared + } + var `private`: (any SyncEngineProtocol)? { + guard let _private else { + reportIssue("Private sync engine has not been set.") return nil } - guard let recordName = SyncMetadata.RecordName(rawValue: recordName) + return _private + } + var `shared`: (any SyncEngineProtocol)? { + guard let _shared else { - reportIssue( - """ - Received 'recordName' in invalid format: \(recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) + reportIssue("Shared sync engine has not been set.") return nil } - let zoneID = try Data.fromDatabaseValue(arguments[1]).flatMap { - let coder = try NSKeyedUnarchiver(forReadingFrom: $0) - coder.requiresSecureCoding = true - return CKRecord(coder: coder)?.recordID.zoneID - } - function(recordName, zoneID) - return nil + return _shared } } -} - -extension String { - package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" - fileprivate static let sqliteDataCloudKitFailure = "SharingGRDB CloudKit Failure" -} - -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -extension URL { - package static func metadatabase(containerIdentifier: String?) throws -> Self { - @Dependency(\.context) var context - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - let base: URL = context == .live - ? .applicationSupportDirectory - : .temporaryDirectory - return base.appending( - component: "\(containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" - ) - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -struct SyncEngines { - let _private: (any SyncEngineProtocol)? - let _shared: (any SyncEngineProtocol)? - init() { - _private = nil - _shared = nil - } - init(private: any SyncEngineProtocol, shared: any SyncEngineProtocol) { - self._private = `private` - self._shared = shared - } - var `private`: (any SyncEngineProtocol)? { - guard let _private - else { - reportIssue("Private sync engine has not been set.") - return nil + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension Database { + /// Attaches the metadatabase to an existing database connection. + /// + /// Invoke this method when preparing your database connection in order to allow querying the + /// ``SyncMetadata`` table (see for more info): + /// + /// ```swift + /// func appDatabase() -> any DatabaseWriter { + /// var configuration = Configuration() + /// configuration.prepareDatabase = { db in + /// db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") + /// … + /// } + /// } + /// ``` + /// + /// See for more information on preparing your database. + /// + /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize + /// data. + public func attachMetadatabase(containerIdentifier: String) throws { + let url = try URL.metadatabase(containerIdentifier: containerIdentifier) + let path = url.path(percentEncoded: false) + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + _ = try DatabasePool(path: path).write { db in + try SQLQueryExpression("SELECT 1").execute(db) + } + try SQLQueryExpression( + """ + ATTACH DATABASE \(bind: path) AS \(quote: .sqliteDataCloudKitSchemaName) + """ + ) + .execute(self) } - return _private } - var `shared`: (any SyncEngineProtocol)? { - guard let _shared - else { - reportIssue("Shared sync engine has not been set.") - return nil + + private func validateSchema( + tables: [any PrimaryKeyedTable.Type], + database: any DatabaseReader + ) throws { + try database.read { db in + for table in tables { + // // TODO: write tests for this + // let columnsWithUniqueConstraints = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + // WHERE "unique" = 1 AND "origin" <> 'pk' + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !columnsWithUniqueConstraints.isEmpty { + // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) + // } + + // // TODO: write tests for this + // let nonNullColumnsWithNoDefault = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) + // WHERE "notnull" = 1 AND "dflt_value" IS NULL + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !nonNullColumnsWithNoDefault.isEmpty { + // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) + // } + } } - return _shared } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Database { - /// Attaches the metadatabase to an existing database connection. - /// - /// Invoke this method when preparing your database connection in order to allow querying the - /// ``SyncMetadata`` table (see for more info): - /// - /// ```swift - /// func appDatabase() -> any DatabaseWriter { - /// var configuration = Configuration() - /// configuration.prepareDatabase = { db in - /// db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") - /// … - /// } - /// } - /// ``` - /// - /// See for more information on preparing your database. - /// - /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize - /// data. - public func attachMetadatabase(containerIdentifier: String) throws { - let url = try URL.metadatabase(containerIdentifier: containerIdentifier) - let path = url.path(percentEncoded: false) - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - _ = try DatabasePool(path: path).write { db in - try SQLQueryExpression("SELECT 1").execute(db) + + public struct UniqueConstraintDisallowed: Error { + let localizedDescription: String + init(table: any PrimaryKeyedTable.Type, columns: [String]) { + localizedDescription = """ + Table '\(table.tableName)' has column\(columns.count == 1 ? "" : "s") with unique \ + constraints: \(columns.map { "'\($0)'" }.joined(separator: ", ")) + """ } - try SQLQueryExpression( - """ - ATTACH DATABASE \(bind: path) AS \(quote: .sqliteDataCloudKitSchemaName) - """ - ) - .execute(self) } -} - -private func validateSchema( - tables: [any PrimaryKeyedTable.Type], - database: any DatabaseReader -) throws { - try database.read { db in - for table in tables { - // // TODO: write tests for this - // let columnsWithUniqueConstraints = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) - // WHERE "unique" = 1 AND "origin" <> 'pk' - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !columnsWithUniqueConstraints.isEmpty { - // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) - // } - - // // TODO: write tests for this - // let nonNullColumnsWithNoDefault = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) - // WHERE "notnull" = 1 AND "dflt_value" IS NULL - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !nonNullColumnsWithNoDefault.isEmpty { - // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) - // } + public struct NonNullColumnMustHaveDefault: Error { + let localizedDescription: String + init(table: any PrimaryKeyedTable.Type, columns: [String]) { + localizedDescription = """ + Table '\(table.tableName)' has non-null column\(columns.count == 1 ? "" : "s") with no \ + default: \(columns.map { "'\($0)'" }.joined(separator: ", ")) + """ } } -} - -public struct UniqueConstraintDisallowed: Error { - let localizedDescription: String - init(table: any PrimaryKeyedTable.Type, columns: [String]) { - localizedDescription = """ - Table '\(table.tableName)' has column\(columns.count == 1 ? "" : "s") with unique \ - constraints: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - """ - } -} -public struct NonNullColumnMustHaveDefault: Error { - let localizedDescription: String - init(table: any PrimaryKeyedTable.Type, columns: [String]) { - localizedDescription = """ - Table '\(table.tableName)' has non-null column\(columns.count == 1 ? "" : "s") with no \ - default: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - """ - } -} -private struct HashablePrimaryKeyedTableType: Hashable { - let type: any PrimaryKeyedTable.Type - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(type)) - } - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.type == rhs.type + private struct HashablePrimaryKeyedTableType: Hashable { + let type: any PrimaryKeyedTable.Type + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(type)) + } + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.type == rhs.type + } } -} #endif From 982e2352ae3ceed0b4fb974cab7e229744424a34 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 28 Jun 2025 17:02:08 -0700 Subject: [PATCH 246/581] Lots of clean up --- Examples/Reminders/RemindersListForm.swift | 1 + .../CloudKit/CloudContainer.swift | 6 + .../CloudKit/CloudKit+StructuredQueries.swift | 104 ++---- .../CloudKit/CloudKitSharing.swift | 4 +- .../StateSerialization+MacroExpansion.swift | 88 +++++ .../CloudKit/StateSerialization.swift | 13 + .../CloudKit/StateSerializationTable.swift | 91 ----- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 183 +++++----- .../CloudKit/SyncEngineProtocol+Live.swift | 21 -- .../CloudKit/SyncEngineProtocol.swift | 12 +- .../CloudKit/SyncMetadata.swift | 14 +- .../CloudKitTests/CloudKitTests.swift | 41 ++- .../CloudKitTests/ForeignKeyTests.swift | 17 +- .../CloudKitTests/MetadataTests.swift | 13 +- .../CloudKitTests/NewTableSyncTests.swift | 6 +- .../NextRecordZoneChangeBatchTests.swift | 28 +- .../CloudKitTests/SharingTests.swift | 25 +- .../CloudKitTests/SyncEngineSetUpTests.swift | 30 +- .../CloudKitTests/SyncEngineTests.swift | 17 +- .../Internal/BaseCloudKitTests.swift | 98 +++--- .../Internal/CloudKit+CustomDump.swift | 77 +++++ .../Internal/CloudKitTestHelpers.swift | 313 +++++++++++++----- Tests/SharingGRDBTests/Internal/Schema.swift | 2 + 23 files changed, 706 insertions(+), 498 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/StateSerialization.swift delete mode 100644 Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift create mode 100644 Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 465a3483..18cbc9c4 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -86,6 +86,7 @@ struct RemindersListForm: View { reportIssue("No 'remindersListID'") return } + // TODO: would be nice to have UNIQUE constraint on RemindersListAsset.remindersListID let existingAsset = try RemindersListAsset .where { $0.remindersListID.eq(remindersListID) } .fetchOne(db) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index 51688445..a2773009 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -6,6 +6,8 @@ package protocol CloudContainer: AnyObject, Equatable, Hashable, Senda var rawValue: CKContainer { get } var privateCloudDatabase: Database { get } + func accept(_ metadata: CKShare.Metadata) async throws -> CKShare + func createContainer(identifier containerIdentifier: String) -> Self var sharedCloudDatabase: Database { get } @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata @@ -20,6 +22,10 @@ extension CloudContainer { } extension CKContainer: CloudContainer { + package func createContainer(identifier containerIdentifier: String) -> Self { + Self(identifier: containerIdentifier) + } + package var rawValue: CKContainer { self } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 528d0123..316de58b 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -10,6 +10,7 @@ extension CKRecord { public var queryBinding: QueryBinding { let archiver = NSKeyedArchiver(requiringSecureCoding: true) queryOutput.encodeSystemFields(with: archiver) + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") return archiver.encodedData.queryBinding } @@ -26,6 +27,11 @@ extension CKRecord { guard let queryOutput = CKRecord(coder: coder) else { throw DecodingError() } + /* + *** -[NSKeyedUnarchiver validateAllowedClass:forKey:] allowed unarchiving safe plist type ''NSString' (0x1f14d83b0) [/System/Library/Frameworks/Foundation.framework]' for key '_recordChangeTag', even though it was not explicitly included in the client allowed classes set: '{( + )}'. This will be disallowed in the future. + */ + queryOutput._recordChangeTag = coder.decodeObject(forKey: "_recordChangeTag") as? String self.init(queryOutput: queryOutput) } @@ -69,6 +75,27 @@ extension CKShare? { public typealias ShareDataRepresentation = CKShare.ShareDataRepresentation? } +extension CKDatabase.Scope { + public struct RawValueRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: CKDatabase.Scope + public var queryBinding: QueryBinding { + queryOutput.rawValue.queryBinding + } + public init(queryOutput: CKDatabase.Scope) { + self.queryOutput = queryOutput + } + public init(decoder: inout some QueryDecoder) throws { + guard + let rawValue = try Int?(decoder: &decoder), + let scope = CKDatabase.Scope(rawValue: rawValue) + else { + throw QueryDecodingError.missingRequiredColumn + } + self.init(queryOutput: scope) + } + } +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { package func update(with row: T, userModificationDate: Date?) { @@ -148,79 +175,10 @@ extension __CKRecordObjCValue { private struct Unbindable: Error {} -// TODO: Move to custom-dump? -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension CKRecord: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - return Mirror( - self, - children: [ - ("recordID", recordID as Any), - ("recordType", recordType as Any), - ("share", share as Any), - ("parent", parent as Any), - ] + self.encryptedValues.allKeys().sorted().map { - ($0, self.encryptedValues[$0] as Any) - }, - displayStyle: .struct - ) - } -} - -extension CKRecord.Reference: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - return Mirror( - self, - children: [ - ("recordID", recordID as Any), - ], - displayStyle: .struct - ) - } -} - -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension CKSyncEngine.RecordZoneChangeBatch: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - Mirror( - self, - children: [ - ("atomicByZone", atomicByZone as Any), - ("recordIDsToDelete", recordIDsToDelete.sorted { lhs, rhs in - lhs.recordName < rhs.recordName - } as Any), - ("recordsToSave", recordsToSave.sorted { lhs, rhs in - lhs.recordID.recordName < rhs.recordID.recordName - } as Any), - ], - displayStyle: .struct - ) - } -} - -extension CKRecord.ID: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - Mirror( - self, - children: [ - "recordName": recordName, - "zoneID": zoneID, - ], - displayStyle: .struct - ) - } -} - -extension CKRecordZone.ID: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - Mirror( - self, - children: [ - "zoneName": zoneName, - "ownerName": ownerName, - ], - displayStyle: .struct - ) +extension CKRecord { + package var _recordChangeTag: String? { + get { self[#function] } + set { self[#function] = newValue } } } #endif diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index d648c0d4..4d3d0b5c 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -102,9 +102,7 @@ extension SyncEngine { } public func acceptShare(metadata: CKShare.Metadata) async throws { - try await syncEngines - .withValue(\.shared)? - .acceptShare(metadata: ShareMetadata(rawValue: metadata)) + try await acceptShare(metadata: ShareMetadata(rawValue: metadata)) } } diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift new file mode 100644 index 00000000..73767e2b --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift @@ -0,0 +1,88 @@ +#if canImport(CloudKit) + import CloudKit + import StructuredQueriesCore + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension StateSerialization { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore + .PrimaryKeyedTableDefinition + { + public typealias QueryValue = StateSerialization + public let scope = StructuredQueriesCore.TableColumn< + QueryValue, CKDatabase.Scope.RawValueRepresentation + >("scope", keyPath: \QueryValue.scope) + public let data = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation + >("data", keyPath: \QueryValue.data) + public var primaryKey: + StructuredQueriesCore.TableColumn + { + self.scope + } + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.scope, QueryValue.columns.data] + } + } + + public struct Draft: StructuredQueriesCore.TableDraft { + public typealias PrimaryTable = StateSerialization + package var scope: CKDatabase.Scope? + package var data: CKSyncEngine.State.Serialization + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = Draft + public let scope = StructuredQueriesCore.TableColumn< + QueryValue, CKDatabase.Scope.RawValueRepresentation? + >("scope", keyPath: \QueryValue.scope) + public let data = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation + >("data", keyPath: \QueryValue.data) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.scope, QueryValue.columns.data] + } + } + public static let columns = TableColumns() + + public static let tableName = StateSerialization.tableName + + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + self.scope = try decoder.decode(CKDatabase.Scope.RawValueRepresentation.self) + let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) + guard let data else { + throw QueryDecodingError.missingRequiredColumn + } + self.data = data + } + + public init(_ other: StateSerialization) { + self.scope = other.scope + self.data = other.data + } + public init( + scope: CKDatabase.Scope? = nil, + data: CKSyncEngine.State.Serialization + ) { + self.scope = scope + self.data = data + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable + { + public static let columns = TableColumns() + public static let tableName = "sqlitedata_icloud_stateSerialization" + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let scope = try decoder.decode(CKDatabase.Scope.RawValueRepresentation.self) + let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) + guard let scope else { + throw QueryDecodingError.missingRequiredColumn + } + guard let data else { + throw QueryDecodingError.missingRequiredColumn + } + self.scope = scope + self.data = data + } + } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift new file mode 100644 index 00000000..9df79e4c --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift @@ -0,0 +1,13 @@ +#if canImport(CloudKit) +import CloudKit +import StructuredQueriesCore + +// @Table("\(String.sqliteDataCloudKitSchemaName)_stateSerialization") +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package struct StateSerialization { + // @Column(as: CKDatabase.Scope.RawValueRepresentation.self, primaryKey: true) + package var scope: CKDatabase.Scope + // @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) + package var data: CKSyncEngine.State.Serialization +} +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift deleted file mode 100644 index 2739d7d5..00000000 --- a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift +++ /dev/null @@ -1,91 +0,0 @@ -#if canImport(CloudKit) -import CloudKit -import StructuredQueriesCore - -// @Table("\(String.sqliteDataCloudKitSchemaName)_stateSerialization") -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package struct StateSerialization { - // @Column(primaryKey: true) - package var scope: CKDatabase.Scope - // @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) - package var data: CKSyncEngine.State.Serialization -} - -extension CKDatabase.Scope: @retroactive QueryBindable { -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore - .PrimaryKeyedTableDefinition - { - public typealias QueryValue = StateSerialization - public let scope = StructuredQueriesCore.TableColumn( - "scope", - keyPath: \QueryValue.scope - ) - public let data = StructuredQueriesCore.TableColumn< - QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation - >("data", keyPath: \QueryValue.data) - public var primaryKey: StructuredQueriesCore.TableColumn { - self.scope - } - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.scope, QueryValue.columns.data] - } - } - public struct Draft: StructuredQueriesCore.TableDraft { - public typealias PrimaryTable = StateSerialization - package var scope: CKDatabase.Scope? - package var data: CKSyncEngine.State.Serialization - public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = StateSerialization.Draft - public let scope = StructuredQueriesCore.TableColumn( - "scope", - keyPath: \QueryValue.scope - ) - public let data = StructuredQueriesCore.TableColumn< - QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation - >("data", keyPath: \QueryValue.data) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.scope, QueryValue.columns.data] - } - } - public static let columns = TableColumns() - public static let tableName = StateSerialization.tableName - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - self.scope = try decoder.decode(CKDatabase.Scope.self) - let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) - guard let data else { - throw QueryDecodingError.missingRequiredColumn - } - self.data = data - } - public init(_ other: StateSerialization) { - self.scope = other.scope - self.data = other.data - } - public init( - scope: CKDatabase.Scope? = nil, - data: CKSyncEngine.State.Serialization - ) { - self.scope = scope - self.data = data - } - } - public static let columns = TableColumns() - public static let tableName = "sqlitedata_icloud_stateSerialization" - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let scope = try decoder.decode(CKDatabase.Scope.self) - let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) - guard let scope else { - throw QueryDecodingError.missingRequiredColumn - } - guard let data else { - throw QueryDecodingError.missingRequiredColumn - } - self.scope = scope - self.data = data - } -} -#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 45bf498c..5dadfd5d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -14,12 +14,12 @@ let database: any DatabaseWriter let logger: Logger - let metadatabase: any DatabaseReader + package let metadatabase: any DatabaseReader let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let privateTables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] let foreignKeysByTableName: [String: [ForeignKey]] - let syncEngines = LockIsolated(SyncEngines()) + package let syncEngines = LockIsolated(SyncEngines()) let defaultSyncEngines: @Sendable (any DatabaseReader, SyncEngine) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) @@ -40,7 +40,10 @@ CKSyncEngine.Configuration( database: container.privateCloudDatabase, stateSerialization: try? metadatabase.read { db in - try StateSerialization.find(CKDatabase.Scope.private).select(\.data).fetchOne(db) + try StateSerialization + .find(BindQueryExpression(CKDatabase.Scope.private)) + .select(\.data) + .fetchOne(db) }, delegate: syncEngine ) @@ -49,7 +52,10 @@ CKSyncEngine.Configuration( database: container.sharedCloudDatabase, stateSerialization: try? metadatabase.read { db in - try StateSerialization.find(CKDatabase.Scope.shared).select(\.data).fetchOne(db) + try StateSerialization + .find(BindQueryExpression(CKDatabase.Scope.shared)) + .select(\.data) + .fetchOne(db) }, delegate: syncEngine ) @@ -68,28 +74,7 @@ ) } - package convenience init( - container: any CloudContainer, - privateSyncEngine: any SyncEngineProtocol, - sharedSyncEngine: any SyncEngineProtocol, - database: any DatabaseWriter, - metadatabaseURL: URL, - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] - ) async throws { - try self.init( - container: container, - defaultSyncEngines: { _, _ in (privateSyncEngine, sharedSyncEngine) }, - database: database, - logger: Logger(.disabled), - metadatabaseURL: metadatabaseURL, - tables: tables, - privateTables: privateTables - ) - try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value - } - - private init( + package init( container: any CloudContainer, defaultSyncEngines: @escaping @Sendable ( any DatabaseReader, @@ -135,7 +120,7 @@ try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value } - nonisolated func setUpSyncEngine( + nonisolated package func setUpSyncEngine( database: any DatabaseWriter, metadatabase: any DatabaseReader ) throws -> Task? { @@ -243,8 +228,6 @@ // TODO: do batches for sake of CKDatabase // only docs we found was about modifies: https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation // recommends limiting to <400 records and <2mb data posted - // TODO: Should we do this in batches now that we save the full 'lastKnowServerRecord'? - // TODO: Or should we denormalize zoneID into the metadata table for easy access? let lastKnownServerRecords = try await metadatabase.read { db in try SyncMetadata .where { @@ -356,6 +339,28 @@ ] ) } + + package func acceptShare(metadata: ShareMetadata) async throws { + guard let metadata = metadata.rawValue + else { + reportIssue("TODO") + return + } + guard let rootRecordID = metadata.hierarchicalRootRecordID + else { + reportIssue("TODO") + return + } + let container = container.createContainer(identifier: metadata.containerIdentifier) + // TODO: do something with the CKShare returned? + _ = try await container.accept(metadata) + try await syncEngines.shared?.fetchChanges( + .init( + scope: .zoneIDs([rootRecordID.zoneID]), + operationGroup: nil + ) + ) + } } extension PrimaryKeyedTable { @@ -401,7 +406,7 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncEngine: CKSyncEngineDelegate { + extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { guard let event = Event(event) else { @@ -416,17 +421,22 @@ switch event { case .accountChange(let changeType): - await handleAccountChange(changeType: changeType) + await handleAccountChange(changeType: changeType, syncEngine: syncEngine) case .stateUpdate(let stateSerialization): handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) case .fetchedDatabaseChanges(let modifications, let deletions): - handleFetchedDatabaseChanges(modifications: modifications, deletions: deletions) + handleFetchedDatabaseChanges( + modifications: modifications, + deletions: deletions, + syncEngine: syncEngine + ) case .sentDatabaseChanges: break case .fetchedRecordZoneChanges(let modifications, let deletions): await handleFetchedRecordZoneChanges( modifications: modifications, - deletions: deletions + deletions: deletions, + syncEngine: syncEngine ) case .sentRecordZoneChanges( let savedRecords, @@ -592,7 +602,10 @@ return batch } - package func handleAccountChange(changeType: CKSyncEngine.Event.AccountChange.ChangeType) async + package func handleAccountChange( + changeType: CKSyncEngine.Event.AccountChange.ChangeType, + syncEngine: any SyncEngineProtocol + ) async { switch changeType { case .signIn: @@ -610,18 +623,16 @@ } return try open(table) } - syncEngines.withValue { - $0.private?.state.add( - pendingRecordZoneChanges: recordNames.map { - .saveRecord( - CKRecord.ID( - recordName: $0.rawValue, - zoneID: Self.defaultZone.zoneID - ) + syncEngine.state.add( + pendingRecordZoneChanges: recordNames.map { + .saveRecord( + CKRecord.ID( + recordName: $0.rawValue, + zoneID: Self.defaultZone.zoneID ) - } - ) - } + ) + } + ) } } case .signOut, .switchAccounts: @@ -652,7 +663,8 @@ package func handleFetchedDatabaseChanges( modifications: [CKRecordZone.ID], - deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)] + deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)], + syncEngine: any SyncEngineProtocol ) { // TODO: How to handle this? Self.$isUpdatingWithServerRecord.withValue(true) { @@ -677,7 +689,8 @@ package func handleFetchedRecordZoneChanges( modifications: [CKRecord] = [], - deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [] + deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], + syncEngine: any SyncEngineProtocol ) async { await Self.$isUpdatingWithServerRecord.withValue(true) { for record in modifications { @@ -691,6 +704,7 @@ } if let shareReference = record.share, // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in + // TODO: could we use 'syncEngine.database' here instead of container? let shareRecord = try? await container.database(for: shareReference.recordID) .record(for: shareReference.recordID), let share = shareRecord as? CKShare @@ -705,13 +719,6 @@ if let table = tablesByName[recordType] { guard let recordName = SyncMetadata.RecordName(recordID: recordID) else { - reportIssue( - """ - Received 'recordName' in invalid format: \(recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) continue } func open>(_: T.Type) { @@ -763,13 +770,6 @@ let failedRecord = failedRecordSave.record guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) else { - reportIssue( - """ - Attempted to delete record with invalid 'recordName': \(failedRecord.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) continue } @@ -789,14 +789,14 @@ switch failedRecordSave.error.code { case .serverRecordChanged: guard let serverRecord = failedRecordSave.error.serverRecord else { continue } + // TODO: do per-field merging here upsertFromServerRecord(serverRecord) refreshLastKnownServerRecord(serverRecord) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) case .zoneNotFound: let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) - // TODO: handle this - //newPendingDatabaseChanges.append(.saveZone(zone)) + newPendingDatabaseChanges.append(.saveZone(zone)) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) clearServerRecord() @@ -804,6 +804,9 @@ newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) clearServerRecord() + case .serverRejectedRequest: + clearServerRecord() + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled, .batchRequestFailed: @@ -834,13 +837,6 @@ else { return } guard let recordName = SyncMetadata.RecordName(recordID: rootRecord.recordID) else { - reportIssue( - """ - Attempted to delete record with invalid 'recordName': \(rootRecord.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) return } @@ -868,30 +864,23 @@ } } - private func upsertFromServerRecord(_ record: CKRecord) { + private func upsertFromServerRecord(_ serverRecord: CKRecord) { Self.$isUpdatingWithServerRecord.withValue(true) { withErrorReporting(.sqliteDataCloudKitFailure) { - guard let table = tablesByName[record.recordType] + guard let table = tablesByName[serverRecord.recordType] else { // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? reportIssue( .sqliteDataCloudKitFailure.appending( """ - : No table to merge from: "\(record.recordType)" + : No table to merge from: "\(serverRecord.recordType)" """ ) ) return } - guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) + guard let recordName = SyncMetadata.RecordName(recordID: serverRecord.recordID) else { - reportIssue( - """ - Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) return } let userModificationDate = @@ -903,7 +892,7 @@ ?? nil guard let userModificationDate, - userModificationDate > record.userModificationDate ?? .distantPast + userModificationDate > serverRecord.userModificationDate ?? .distantPast else { // TODO: This should be fetched early and held onto (like 'ForeignKey') let columnNames = try database.read { db in @@ -919,11 +908,11 @@ var query: QueryFragment = "INSERT INTO \(table) (" query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) query.append(") VALUES (") - let encryptedValues = record.encryptedValues + let encryptedValues = serverRecord.encryptedValues query.append( columnNames .map { columnName in - if let asset = record[columnName] as? CKAsset { + if let asset = serverRecord[columnName] as? CKAsset { return (try? asset.fileURL.map { try Data(contentsOf: $0) })? .queryFragment ?? "NULL" } else { @@ -950,7 +939,7 @@ ) // TODO: Append more ON CONFLICT clauses for each unique constraint? // TODO: Use WHERE to scope the update? - guard let metadata = SyncMetadata(record: record) + guard let metadata = SyncMetadata(record: serverRecord) else { reportIssue("???") return @@ -961,8 +950,8 @@ .insert { metadata } onConflictDoUpdate: { - $0.lastKnownServerRecord = record - $0.userModificationDate = record.userModificationDate + $0.lastKnownServerRecord = serverRecord + $0.userModificationDate = serverRecord.userModificationDate } .execute(db) } @@ -976,13 +965,6 @@ Self.$isUpdatingWithServerRecord.withValue(true) { guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) else { - reportIssue( - """ - Attempted to delete record with invalid 'recordName': \(record.recordID.recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) return } let metadata = metadataFor(recordName: recordName) @@ -1066,13 +1048,6 @@ } guard let recordName = SyncMetadata.RecordName(rawValue: recordName) else { - reportIssue( - """ - Received 'recordName' in invalid format: \(recordName) - - 'recordName' should be formatted as "uuid:tableName". - """ - ) return nil } let zoneID = try Data.fromDatabaseValue(arguments[1]).flatMap { @@ -1110,7 +1085,7 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - struct SyncEngines { + package struct SyncEngines { let _private: (any SyncEngineProtocol)? let _shared: (any SyncEngineProtocol)? init() { @@ -1121,7 +1096,7 @@ self._private = `private` self._shared = shared } - var `private`: (any SyncEngineProtocol)? { + package var `private`: (any SyncEngineProtocol)? { guard let _private else { reportIssue("Private sync engine has not been set.") @@ -1129,7 +1104,7 @@ } return _private } - var `shared`: (any SyncEngineProtocol)? { + package var `shared`: (any SyncEngineProtocol)? { guard let _shared else { reportIssue("Shared sync engine has not been set.") diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift index 434b2858..3aa0cdb3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift @@ -3,27 +3,6 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine: SyncEngineProtocol { - package func acceptShare(metadata: ShareMetadata) async throws { - guard let metadata = metadata.rawValue - else { - reportIssue("TODO") - return - } - guard let rootRecordID = metadata.hierarchicalRootRecordID - else { - reportIssue("TODO") - return - } - let container = CKContainer(identifier: metadata.containerIdentifier) - try await container.accept(metadata) - try await fetchChanges( - .init( - scope: .zoneIDs([rootRecordID.zoneID]), - operationGroup: nil - ) - ) - } - package func recordZoneChangeBatch( pendingChanges: [PendingRecordZoneChange], recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index 447cd553..c530cf5f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -1,6 +1,16 @@ #if canImport(CloudKit) import CloudKit +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package protocol SyncEngineDelegate: AnyObject, Sendable { + func handleEvent(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) async + func nextRecordZoneChangeBatch( + reason: CKSyncEngine.SyncReason, + options: CKSyncEngine.SendChangesOptions, + syncEngine: any SyncEngineProtocol + ) async -> CKSyncEngine.RecordZoneChangeBatch? +} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package protocol SyncEngineProtocol: AnyObject, Sendable { associatedtype State: CKSyncEngineStateProtocol @@ -9,8 +19,8 @@ package protocol SyncEngineProtocol: AnyObject, Sendable { var database: Database { get } var state: State { get } - func acceptShare(metadata: ShareMetadata) async throws func cancelOperations() async + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws func recordZoneChangeBatch( pendingChanges: [CKSyncEngine.PendingRecordZoneChange], recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 0fdc8ba2..ba6f16a5 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -76,12 +76,16 @@ extension SyncMetadata { } public init?(rawValue: String) { - guard let colonIndex = rawValue.firstIndex(of: ":") - else { - return nil - } - guard let id = UUID(uuidString: String(rawValue[rawValue.startIndex.. = PrimaryKeyedTable & Sendable @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -42,25 +32,15 @@ class BaseCloudKitTests: @unchecked Sendable { try db.seed { seeds } } }() - let privateSyncEngine = MockSyncEngine( - database: MockCloudDatabase(databaseScope: .private), - scope: .private, - state: MockSyncEngineState() - ) - let sharedSyncEngine = MockSyncEngine( - database: MockCloudDatabase(databaseScope: .shared), - scope: .shared, - state: MockSyncEngineState() - ) - _privateSyncEngine = privateSyncEngine - _sharedSyncEngine = sharedSyncEngine + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) _syncEngine = try await SyncEngine( container: MockCloudContainer( - privateCloudDatabase: privateSyncEngine.database, - sharedCloudDatabase: sharedSyncEngine.database + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase ), - privateSyncEngine: privateSyncEngine, - sharedSyncEngine: sharedSyncEngine, + privateDatabase: privateDatabase, + sharedDatabase: sharedDatabase, database: database, metadatabaseURL: URL.metadatabase(containerIdentifier: testContainerIdentifier), tables: [ @@ -82,16 +62,60 @@ class BaseCloudKitTests: @unchecked Sendable { deinit { if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - sharedSyncEngine.assertFetchChangesScopes([]) - sharedSyncEngine.state.assertPendingDatabaseChanges([]) - sharedSyncEngine.state.assertPendingRecordZoneChanges([]) - sharedSyncEngine.assertAcceptedShareMetadata([]) - privateSyncEngine.assertFetchChangesScopes([]) - privateSyncEngine.state.assertPendingDatabaseChanges([]) - privateSyncEngine.state.assertPendingRecordZoneChanges([]) - privateSyncEngine.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([]) } else { Issue.record("Tests must be run on iOS 17+,m macOS 14+, tvOS 17+ and watchOS 10+.") } } } + +extension SyncEngine { + var `private`: MockSyncEngine { + syncEngines.private as! MockSyncEngine + } + var shared: MockSyncEngine { + syncEngines.shared as! MockSyncEngine + } + convenience init( + container: any CloudContainer, + privateDatabase: MockCloudDatabase, + sharedDatabase: MockCloudDatabase, + database: any DatabaseWriter, + metadatabaseURL: URL, + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] + ) async throws { + try self.init( + container: container, + defaultSyncEngines: { _, syncEngine in + ( + MockSyncEngine( + database: privateDatabase, + delegate: syncEngine, + scope: .private, + state: MockSyncEngineState() + ), + MockSyncEngine( + database:sharedDatabase, + delegate: syncEngine, + scope: .shared, + state: MockSyncEngineState() + ) + ) + }, + database: database, + logger: Logger(.disabled), + metadatabaseURL: metadatabaseURL, + tables: tables, + privateTables: privateTables + ) + try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value + } +} diff --git a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift new file mode 100644 index 00000000..a42a7e6a --- /dev/null +++ b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift @@ -0,0 +1,77 @@ +import CloudKit +import CustomDump + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +extension CKRecord: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + return Mirror( + self, + children: [ + ("recordID", recordID as Any), + ("recordType", recordType as Any), + ("share", share as Any), + ("parent", parent as Any), + ] + self.encryptedValues.allKeys().sorted().map { + ($0, self.encryptedValues[$0] as Any) + }, + displayStyle: .struct + ) + } +} + +extension CKRecord.Reference: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + return Mirror( + self, + children: [ + ("recordID", recordID as Any), + ], + displayStyle: .struct + ) + } +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +extension CKSyncEngine.RecordZoneChangeBatch: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + Mirror( + self, + children: [ + ("atomicByZone", atomicByZone as Any), + ("recordIDsToDelete", recordIDsToDelete.sorted { lhs, rhs in + lhs.recordName < rhs.recordName + } as Any), + ("recordsToSave", recordsToSave.sorted { lhs, rhs in + lhs.recordID.recordName < rhs.recordID.recordName + } as Any), + ], + displayStyle: .struct + ) + } +} + +extension CKRecord.ID: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + Mirror( + self, + children: [ + "recordName": recordName, + "zoneID": zoneID, + ], + displayStyle: .struct + ) + } +} + +extension CKRecordZone.ID: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + Mirror( + self, + children: [ + "zoneName": zoneName, + "ownerName": ownerName, + ], + displayStyle: .struct + ) + } +} diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index b11fa165..cb7d83c6 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -1,7 +1,9 @@ import CloudKit import ConcurrencyExtras import CustomDump +import OrderedCollections import SharingGRDBCore +import Testing extension PrimaryKeyedTable { static func recordID(for id: UUID, zoneID: CKRecordZone.ID? = nil) -> CKRecord.ID { @@ -15,17 +17,20 @@ extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) final class MockSyncEngine: SyncEngineProtocol { let database: MockCloudDatabase + let delegate: any SyncEngineDelegate private let _state: LockIsolated - private let _fetchChangesScopes = LockIsolated>([]) + private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) private let _acceptedShareMetadata = LockIsolated>([]) let scope: CKDatabase.Scope init( database: MockCloudDatabase, + delegate: any SyncEngineDelegate, scope: CKDatabase.Scope, state: MockSyncEngineState ) { self.database = database + self.delegate = delegate self.scope = scope self._state = LockIsolated(state) } @@ -38,37 +43,85 @@ final class MockSyncEngine: SyncEngineProtocol { _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } } + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { + // TODO: do something here + } + func recordZoneChangeBatch( pendingChanges: [CKSyncEngine.PendingRecordZoneChange], recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let savedRecordIDs: [CKRecord.ID] = state.pendingRecordZoneChanges.compactMap { - guard case .saveRecord(let recordID) = $0 - else { return nil } - return recordID - } var recordsToSave: [CKRecord] = [] - for recordID in savedRecordIDs { - guard let record = await recordProvider(recordID) - else { continue } - recordsToSave.append(record) - } - let recordIDsToDelete: [CKRecord.ID] = state.pendingRecordZoneChanges.compactMap { - guard case .deleteRecord(let recordID) = $0 - else { return nil } - return recordID + var recordIDsSkipped: [CKRecord.ID] = [] + var recordIDsToDelete: [CKRecord.ID] = [] + for pendingChange in pendingChanges { + switch pendingChange { + case .saveRecord(let recordID): + guard let record = await recordProvider(recordID) + else { + recordIDsSkipped.append(recordID) + continue + } + recordsToSave.append(record) + case .deleteRecord(let recordID): + recordIDsToDelete.append(recordID) + @unknown default: + fatalError() + } } - - state.remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) }) - state.remove(pendingRecordZoneChanges: recordIDsToDelete.map { .deleteRecord($0) }) - - _ = await database.modifyRecords( + let (saveResults, deleteResults) = database.modifyRecords( saving: recordsToSave, deleting: recordIDsToDelete, savePolicy: .ifServerRecordUnchanged, atomically: true ) + var savedRecords: [CKRecord] = [] + var failedRecordSaves: [(record: CKRecord, error: CKError)] = [] + var deletedRecordIDs: [CKRecord.ID] = [] + var failedRecordDeletes: [CKRecord.ID: CKError] = [:] + for (recordID, result) in saveResults { + switch result { + case .success(let record): + savedRecords.append(record) + case .failure(let error as CKError): + guard let record = recordsToSave.first(where: { $0.recordID == recordID }) + else { fatalError("\(recordID.debugDescription) not found in pending changes") } + failedRecordSaves.append((record: record, error: error)) + case .failure: + fatalError("Mocks should only raise 'CKError' values.") + } + } + for (recordID, result) in deleteResults { + switch result { + case .success: + deletedRecordIDs.append(recordID) + case .failure(let error as CKError): + failedRecordDeletes[recordID] = error + case .failure: + fatalError("Mocks should only raise 'CKError' values.") + } + } + + state.remove( + pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) } + + recordIDsSkipped.map { .saveRecord($0) } + ) + state.remove( + pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } + ) + + await delegate + .handleEvent( + .sentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes + ), + syncEngine: self + ) + return CKSyncEngine.RecordZoneChangeBatch( recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete @@ -76,7 +129,7 @@ final class MockSyncEngine: SyncEngineProtocol { } func assertFetchChangesScopes( - _ scopes: Set, + _ scopes: [CKSyncEngine.FetchChangesOptions.Scope], fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, @@ -121,9 +174,9 @@ final class MockSyncEngine: SyncEngineProtocol { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectable { - private let _pendingRecordZoneChanges = LockIsolated>([] + private let _pendingRecordZoneChanges = LockIsolated>([] ) - private let _pendingDatabaseChanges = LockIsolated>([]) + private let _pendingDatabaseChanges = LockIsolated>([]) private let fileID: StaticString private let filePath: StaticString private let line: UInt @@ -142,7 +195,7 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl } func assertPendingRecordZoneChanges( - _ changes: Set, + _ changes: OrderedSet, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, @@ -150,8 +203,8 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl ) { _pendingRecordZoneChanges.withValue { expectNoDifference( - changes, - $0, + Set(changes), + Set($0), fileID: fileID, filePath: filePath, line: line, @@ -162,7 +215,7 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl } func assertPendingDatabaseChanges( - _ changes: Set, + _ changes: OrderedSet, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, @@ -170,8 +223,8 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl ) { _pendingDatabaseChanges.withValue { expectNoDifference( - changes, - $0, + Set(changes), + Set($0), fileID: fileID, filePath: filePath, line: line, @@ -187,7 +240,7 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { self._pendingRecordZoneChanges.withValue { - $0.formUnion(pendingRecordZoneChanges) + $0.append(contentsOf: pendingRecordZoneChanges) } } func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { @@ -197,7 +250,7 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl } func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { self._pendingDatabaseChanges.withValue { - $0.formUnion(pendingDatabaseChanges) + $0.append(contentsOf: pendingDatabaseChanges) } } func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { @@ -227,8 +280,8 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl } } -actor MockCloudDatabase: CloudDatabase { - var storage: [CKRecord.ID: CKRecord] = [:] +final class MockCloudDatabase: CloudDatabase { + let storage = LockIsolated<[CKRecord.ID: CKRecord]>([:]) let databaseScope: CKDatabase.Scope struct RecordNotFound: Error {} @@ -240,14 +293,16 @@ actor MockCloudDatabase: CloudDatabase { func record(for recordID: CKRecord.ID) throws -> CKRecord { guard let record = storage[recordID] else { throw RecordNotFound() } + guard let record = record.copy() as? CKRecord + else { fatalError("Could not copy CKRecord.") } return record } func records( for ids: [CKRecord.ID], desiredKeys: [CKRecord.FieldKey]? - ) throws -> [CKRecord.ID : Result] { - var results: [CKRecord.ID : Result] = [:] + ) throws -> [CKRecord.ID: Result] { + var results: [CKRecord.ID: Result] = [:] for id in ids { results[id] = Result { try record(for: id) } } @@ -255,28 +310,92 @@ actor MockCloudDatabase: CloudDatabase { } func modifyRecords( - saving recordsToSave: [CKRecord], - deleting recordIDsToDelete: [CKRecord.ID], - savePolicy: CKModifyRecordsOperation.RecordSavePolicy, - atomically: Bool + saving recordsToSave: [CKRecord] = [], + deleting recordIDsToDelete: [CKRecord.ID] = [], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged, + atomically: Bool = true ) -> ( - saveResults: [CKRecord.ID : Result], - deleteResults: [CKRecord.ID : Result] + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] ) { - for recordToSave in recordsToSave { - storage[recordToSave.recordID] = recordToSave + storage.withValue { storage in + var saveResults: [CKRecord.ID: Result] = [:] + var deleteResults: [CKRecord.ID: Result] = [:] + + switch savePolicy { + case .ifServerRecordUnchanged: + for recordToSave in recordsToSave { + let existingRecord = storage[recordToSave.recordID] + + func saveRecordToDatabase() { + guard let copy = recordToSave.copy() as? CKRecord + else { fatalError("Could not copy CKRecord.") } + copy._recordChangeTag = UUID().uuidString + storage[recordToSave.recordID] = copy + saveResults[recordToSave.recordID] = .success(copy) + } + + switch (existingRecord, recordToSave._recordChangeTag) { + case (.some(let existingRecord), .some(let recordToSaveChangeTag)): + // We are trying to save a record with a change tag that also already exists in the + // DB. If the tags match, we can save the record. Otherwise, we notify the sync engine + // that the server record has changed since it was last synced. + if existingRecord._recordChangeTag == recordToSaveChangeTag { + precondition(existingRecord._recordChangeTag != nil) + saveRecordToDatabase() + } else { + saveResults[recordToSave.recordID] = .failure( + CKError( + .serverRecordChanged, + userInfo: [ + CKRecordChangedErrorServerRecordKey: existingRecord as Any, + CKRecordChangedErrorClientRecordKey: recordToSave, + ] + ) + ) + } + break + case (.some(let existingRecord), .none): + // We are trying to save a record that does not have a change tag yet also already + // exists in the DB. This means the user has created a new CKRecord from scratch, + // giving it a new identity, rather than leveraging an existing CKRecord. + Issue.record( + """ + A new identity was created for an existing 'CKRecord'. Rather than creating + 'CKRecord' from scratch for an existing record, use the database to fetch the + current record. + """ + ) + saveResults[recordToSave.recordID] = .failure( + CKError( + .serverRejectedRequest, + userInfo: [ + CKRecordChangedErrorServerRecordKey: existingRecord as Any, + CKRecordChangedErrorClientRecordKey: recordToSave, + ] + ) + ) + case (.none, .some): + // We are trying to save a record with a change tag but it does not exist in the DB. + // This means the record was deleted by another device. + saveResults[recordToSave.recordID] = .failure(CKError(.unknownItem)) + case (.none, .none): + // We are trying to save a record with no change tag and no existing record in the DB. + // This means it's a brand new record. + saveRecordToDatabase() + } + } + case .allKeys, .changedKeys: + fatalError() + @unknown default: + fatalError() + } + for recordIDToDelete in recordIDsToDelete { + storage[recordIDToDelete] = nil + deleteResults[recordIDToDelete] = .success(()) + } + return (saveResults: saveResults, deleteResults: deleteResults) } - for recordIDToDelete in recordIDsToDelete { - storage[recordIDToDelete] = nil - } - return ( - saveResults: Dictionary( - uniqueKeysWithValues: recordsToSave.map { ($0.recordID, .success($0)) } - ), - deleteResults: Dictionary( - uniqueKeysWithValues: recordIDsToDelete.map { ($0, .success(())) } - ) - ) } nonisolated static func == (lhs: MockCloudDatabase, rhs: MockCloudDatabase) -> Bool { @@ -288,6 +407,25 @@ actor MockCloudDatabase: CloudDatabase { } } +extension MockCloudDatabase: CustomDumpReflectable { + var customDumpMirror: Mirror { + Mirror( + self, + children: [ + "databaseScope": databaseScope, + "storage": storage + .value + .sorted { + ($0.value.recordType, $0.value.recordID.recordName) + < ($1.value.recordType, $1.value.recordID.recordName) + } + .map(\.value), + ], + displayStyle: .struct + ) + } +} + final class MockCloudContainer: CloudContainer { let privateCloudDatabase: MockCloudDatabase let sharedCloudDatabase: MockCloudDatabase @@ -305,6 +443,25 @@ final class MockCloudContainer: CloudContainer { fatalError() } + func accept(_ metadata: CKShare.Metadata) async throws -> CKShare { + fatalError() + } + + func createContainer(identifier containerIdentifier: String) -> Self { + @Dependency(\.mockCloudContainers) var mockCloudContainers + return mockCloudContainers.withValue { storage in + let container = + storage[containerIdentifier] + ?? MockCloudContainer( + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ) + storage[containerIdentifier] = container + // TODO: possible to work around? + return container as! Self + } + } + static func == (lhs: MockCloudContainer, rhs: MockCloudContainer) -> Bool { lhs === rhs } @@ -314,6 +471,22 @@ final class MockCloudContainer: CloudContainer { } } +private enum MockCloudContainersKey: TestDependencyKey { + static var testValue: LockIsolated<[String: MockCloudContainer]> { + LockIsolated<[String: MockCloudContainer]>([:]) + } +} +extension DependencyValues { + var mockCloudContainers: LockIsolated<[String: MockCloudContainer]> { + get { + self[MockCloudContainersKey.self] + } + set { + self[MockCloudContainersKey.self] = newValue + } + } +} + private func comparePendingRecordZoneChange( _ lhs: CKSyncEngine.PendingRecordZoneChange, _ rhs: CKSyncEngine.PendingRecordZoneChange @@ -348,35 +521,3 @@ private func comparePendingDatabaseChange( false } } - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngine.FetchChangesOptions.Scope: @retroactive Hashable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.all, .all): - return true - case (.allExcluding(let lhs), .allExcluding(let rhs)): - return lhs == rhs - case (.zoneIDs(let lhs), .zoneIDs(let rhs)): - return lhs == rhs - case (.all, _), (.allExcluding, _), (.zoneIDs, _): - return false - @unknown default: - return false - } - } - public func hash(into hasher: inout Hasher) { - switch self { - case .all: - hasher.combine(0) - case .allExcluding(let zoneIDs): - hasher.combine(1) - hasher.combine(zoneIDs) - case .zoneIDs(let zoneIDs): - hasher.combine(2) - hasher.combine(zoneIDs) - @unknown default: - hasher.combine(3) - } - } -} diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index dd47f9d5..01613250 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -3,6 +3,7 @@ import SharingGRDB @Table struct Reminder: Equatable, Identifiable { let id: UUID + var isCompleted = false var title = "" var remindersListID: RemindersList.ID } @@ -94,6 +95,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "isCompleted" INTEGER NOT NULL DEFAULT 0, "title" TEXT NOT NULL DEFAULT '', "remindersListID" TEXT NOT NULL, From c501dc24982d858616c56a350be2f8c65e1c8810 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 28 Jun 2025 18:28:50 -0700 Subject: [PATCH 247/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../CloudKitTests/CloudKitTests.swift | 355 ++++++++++--- .../CloudKitTests/ForeignKeyTests.swift | 466 +++++++++++++++--- .../CloudKitTests/MetadataTests.swift | 256 +++++++--- .../CloudKitTests/NewTableSyncTests.swift | 104 ++-- .../NextRecordZoneChangeBatchTests.swift | 396 +++++---------- .../CloudKitTests/RecordTypeTests.swift | 2 + .../CloudKitTests/SharingTests.swift | 131 +++-- .../CloudKitTests/SyncEngineSetUpTests.swift | 3 +- .../Internal/BaseCloudKitTests.swift | 2 +- .../Internal/CloudKit+CustomDump.swift | 162 +++--- .../Internal/CloudKitTestHelpers.swift | 212 +++++--- 12 files changed, 1343 insertions(+), 748 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5dadfd5d..999ca711 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -23,7 +23,7 @@ let defaultSyncEngines: @Sendable (any DatabaseReader, SyncEngine) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) - let container: any CloudContainer + package let container: any CloudContainer public convenience init( container: CKContainer, diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index a075f553..25318b5a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -128,9 +128,31 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } try await database.asyncWrite { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 @@ -153,25 +175,34 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) - - let record = CKRecord( - recordType: "remindersLists", - recordID: RemindersList.recordID(for: UUID(1)) - ) - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [record], - syncEngine: syncEngine.private - ) - expectNoDifference( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), - RemindersList(id: UUID(1), title: "Personal") - ) + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } let metadata = - try await database.asyncWrite { db in + try await database.read { db in try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) } #expect(metadata != nil) @@ -218,33 +249,91 @@ extension BaseCloudKitTests { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func insertUpdateDelete() throws { - try database.write { db in + @Test func insertUpdateDelete() async throws { + try await database.asyncWrite { db in try RemindersList .insert { RemindersList(id: UUID(1), title: "Personal") } .execute(db) } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) - try database.write { db in + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await database.asyncWrite { db in try RemindersList .find(UUID(1)) .update { $0.title = "Work" } .execute(db) } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) - try database.write { db in + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Work", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await database.asyncWrite { db in try RemindersList .find(UUID(1)) .delete() .execute(db) } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .deleteRecord(RemindersList.recordID(for: UUID(1))) - ]) + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -254,16 +343,34 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } - let record = CKRecord( - recordType: "remindersLists", - recordID: RemindersList.recordID(for: UUID(1)) - ) let userModificationDate = try #require( - try await database.asyncWrite { db in + try await database.read { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -271,29 +378,49 @@ extension BaseCloudKitTests { } ) - // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? - record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString - record.encryptedValues[RemindersList.columns.title.name] = "Work" + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) + record.encryptedValues["title"] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(60) record.userModificationDate = serverModificationDate - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [record], - syncEngine: syncEngine.private - ) + _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) + expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Work") ) let metadata = try #require( - try await database.asyncWrite { db in + try await database.read { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) } ) - // TODO: Control dates in SQLite in order to get consistent passing on float comparison - #expect(abs(metadata.userModificationDate!.timeIntervalSince(serverModificationDate)) < 0.1) + #expect(metadata.userModificationDate == serverModificationDate) + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Work", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -303,13 +430,32 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) - let record = CKRecord( - recordType: "remindersLists", - recordID: RemindersList.recordID(for: UUID(1)) - ) + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + let userModificationDate = try #require( try await database.asyncWrite { db in try SyncMetadata @@ -319,15 +465,12 @@ extension BaseCloudKitTests { } ) - // TODO: Should we omit primary key from `encryptedValues` since it already exists on recordName? - record.encryptedValues[RemindersList.columns.id.name] = UUID(1).uuidString - record.encryptedValues[RemindersList.columns.title.name] = "Work" + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) + record.encryptedValues["title"] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) record.userModificationDate = serverModificationDate - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [record], - syncEngine: syncEngine.private - ) + _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) + expectNoDifference( try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Personal") @@ -341,6 +484,31 @@ extension BaseCloudKitTests { } ) #expect(metadata.userModificationDate == userModificationDate) + // TODO: The title below should be work. + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Work", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:30:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -350,21 +518,38 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))) - ]) + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) + _ = await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]) - let record = CKRecord( - recordType: "remindersLists", - recordID: RemindersList.recordID(for: UUID(1)) - ) - await syncEngine.handleFetchedRecordZoneChanges( - deletions: [(recordID: record.recordID, recordType: record.recordType)], - syncEngine: syncEngine.private - ) #expect( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchCount(db) } }() - == 0 + try await database.read { db in try RemindersList.find(UUID(1)).fetchAll(db) } + == [] ) let metadata = try await database.asyncWrite { db in try SyncMetadata @@ -372,6 +557,20 @@ extension BaseCloudKitTests { .fetchOne(db) } #expect(metadata == nil) + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 3178cc1e..51e8eeb3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -11,51 +11,134 @@ extension BaseCloudKitTests { @MainActor final class ForeignKeyTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteCascade() throws { - try database.write { db in + @Test func deleteCascade() async throws { + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) Reminder(id: UUID(2), title: "Walk", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Haircut", remindersListID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(3))), - ]) - try database.write { db in + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Groceries", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000002", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Walk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await database.asyncWrite { db in try RemindersList.find(UUID(1)).delete().execute(db) } - try database.read { db in + try await database.read { db in try #expect(Reminder.all.fetchAll(db) == []) } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .deleteRecord(RemindersList.recordID(for: UUID(1))), - .deleteRecord(Reminder.recordID(for: UUID(1))), - .deleteRecord(Reminder.recordID(for: UUID(2))), - .deleteRecord(Reminder.recordID(for: UUID(3))), - ]) + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteSetNull() throws { - try database.write { db in + @Test func deleteSetNull() async throws { + try await database.asyncWrite { db in try db.seed { Parent(id: UUID(1)) ChildWithOnDeleteSetNull(id: UUID(1), parentID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(Parent.recordID(for: UUID(1))), - .saveRecord(ChildWithOnDeleteSetNull.recordID(for: UUID(1))), - ]) - try database.write { db in + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "childWithOnDeleteSetNulls", + parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + parentID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await database.asyncWrite { db in try Parent.find(UUID(1)).delete().execute(db) } - try database.read { db in + try await database.read { db in try expectNoDifference( ChildWithOnDeleteSetNull.all.fetchAll(db), [ @@ -63,32 +146,95 @@ extension BaseCloudKitTests { ] ) } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .deleteRecord(Parent.recordID(for: UUID(1))), - .saveRecord(ChildWithOnDeleteSetNull.recordID(for: UUID(1))), - ]) + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "childWithOnDeleteSetNulls", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func updateCascade() throws { - try database.write { db in + @Test func updateCascade() async throws { + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) Reminder(id: UUID(3), title: "Walk", remindersListID: UUID(1)) - Reminder(id: UUID(4), title: "Haircut", remindersListID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(3))), - .saveRecord(Reminder.recordID(for: UUID(4))), - ]) - try database.write { db in + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000002", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Groceries", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000003", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Walk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await database.asyncWrite { db in try RemindersList.find(UUID(1)).update { $0.id = UUID(9) }.execute(db) } - try database.read { db in + try await database.read { db in try expectNoDifference( Reminder.all.fetchAll(db), [ @@ -98,56 +244,200 @@ extension BaseCloudKitTests { ] ) } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(9))), - .saveRecord(Reminder.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(3))), - .saveRecord(Reminder.recordID(for: UUID(4))), - ]) + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000002", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000009", + title: "Groceries", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000003", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000009", + title: "Walk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [3]: CKRecord( + recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000009", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteRestrict() throws { - try database.write { db in + @Test func deleteRestrict() async throws { + try await database.asyncWrite { db in try db.seed { Parent(id: UUID(1)) ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(Parent.recordID(for: UUID(1))), - .saveRecord(ChildWithOnDeleteRestrict.recordID(for: UUID(1))), - ]) - do { - let error = #expect(throws: DatabaseError.self) { - try self.database.write { db in - try Parent.find(UUID(1)).delete().execute(db) - } - } - #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try database.read { db in - try expectNoDifference( - ChildWithOnDeleteRestrict.all.fetchAll(db), - [ - ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "childWithOnDeleteRestricts", + parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + parentID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] ) + ) + """ + } + + let error = #expect(throws: DatabaseError.self) { + try self.database.write { db in + try Parent.find(UUID(1)).delete().execute(db) } } + #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) + try await database.read { db in + try expectNoDifference( + ChildWithOnDeleteRestrict.all.fetchAll(db), + [ + ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) + ] + ) + } + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "childWithOnDeleteRestricts", + parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + parentID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func updateRestrict() throws { - try database.write { db in + @Test func updateRestrict() async throws { + try await database.asyncWrite { db in try db.seed { Parent(id: UUID(1)) ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(Parent.recordID(for: UUID(1))), - .saveRecord(ChildWithOnDeleteRestrict.recordID(for: UUID(1))), - ]) + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "childWithOnDeleteRestricts", + parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + parentID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } let error = #expect(throws: DatabaseError.self) { try self.database.write { db in @@ -155,7 +445,7 @@ extension BaseCloudKitTests { } } #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try database.read { db in + try await database.read { db in try expectNoDifference( ChildWithOnDeleteRestrict.all.fetchAll(db), [ @@ -163,6 +453,40 @@ extension BaseCloudKitTests { ] ) } + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "childWithOnDeleteRestricts", + parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + parentID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 0653bd10..4f184b32 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -11,21 +11,62 @@ extension BaseCloudKitTests { @MainActor final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func parentRecordName() throws { - try database.write { db in + @Test func parentRecordName() async throws { + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") RemindersList(id: UUID(2), title: "Work") Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(RemindersList.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(1))), - ]) - try database.write { db in + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Groceries", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [2]: CKRecord( + recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000002", + title: "Work", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await database.read { db in let reminderMetadata = try #require( try SyncMetadata .find(Reminder.recordName(for: UUID(1))) @@ -34,7 +75,7 @@ extension BaseCloudKitTests { #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(1))) } - try database.write { db in + try await database.asyncWrite { db in try Reminder.find(UUID(1)) .update { $0.remindersListID = UUID(2) } .execute(db) @@ -45,13 +86,56 @@ extension BaseCloudKitTests { ) #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(2))) } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(Reminder.recordID(for: UUID(1))) - ]) + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000002", + title: "Groceries", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [2]: CKRecord( + recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000002", + title: "Work", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } - @Test func noParentRecordForRecordsWithMultipleForeignKeys() throws { - try database.write { db in + @Test func noParentRecordForRecordsWithMultipleForeignKeys() async throws { + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) @@ -59,31 +143,74 @@ extension BaseCloudKitTests { ReminderTag(id: UUID(1), reminderID: UUID(1), tagID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(1))), - .saveRecord(Tag.recordID(for: UUID(1))), - .saveRecord(ReminderTag.recordID(for: UUID(1))), - ]) - - try database.write { db in - let tagMetadata = try #require( - try SyncMetadata - .find(Tag.recordName(for: UUID(1))) - .fetchOne(db) - ) - #expect(tagMetadata.parentRecordName == nil) - let reminderTagMetadata = try #require( - try SyncMetadata - .find(Tag.recordName(for: UUID(1))) - .fetchOne(db) + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminderTags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminderTags", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + reminderID: "00000000-0000-0000-0000-000000000001", + tagID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Groceries", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [3]: CKRecord( + recordID: CKRecord.ID(1:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "weekend", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - #expect(reminderTagMetadata.parentRecordName == nil) + """ + } + + let parentRecordNames = try await database.read { db in + try SyncMetadata + .where { $0.recordType != Reminder.tableName } + .select(\.parentRecordName) + .fetchAll(db) } + #expect(parentRecordNames.allSatisfy { $0 == nil }) } - @Test func recordType() throws { - try database.write { db in + @Test func recordType() async throws { + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -91,30 +218,25 @@ extension BaseCloudKitTests { Reminder(id: UUID(4), title: "Groceries", remindersListID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(3))), - .saveRecord(Reminder.recordID(for: UUID(4))), - ]) - - try database.read { db in - let reminderMetadata = + + await syncEngine.processBatch() + + let reminderMetadata = try await database.read { db in try SyncMetadata .where { $0.recordType == Reminder.tableName } .fetchAll(db) - #expect( - reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: UUID(2)), - Reminder.recordName(for: UUID(3)), - Reminder.recordName(for: UUID(4)), - ] - ) } + #expect( + reminderMetadata.map(\.recordName) == [ + Reminder.recordName(for: UUID(2)), + Reminder.recordName(for: UUID(3)), + Reminder.recordName(for: UUID(4)), + ] + ) } - @Test func parentRecordType() throws { - try database.write { db in + @Test func parentRecordType() async throws { + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -122,14 +244,10 @@ extension BaseCloudKitTests { Reminder(id: UUID(4), title: "Groceries", remindersListID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(3))), - .saveRecord(Reminder.recordID(for: UUID(4))), - ]) - - try database.read { db in + + await syncEngine.processBatch() + + try await database.read { db in let reminderMetadata = try SyncMetadata .where { $0.parentRecordType == RemindersList.tableName } @@ -144,8 +262,8 @@ extension BaseCloudKitTests { } } - @Test func parentRecordPrimaryKey() throws { - try database.write { db in + @Test func parentRecordPrimaryKey() async throws { + try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -153,14 +271,10 @@ extension BaseCloudKitTests { Reminder(id: UUID(4), title: "Groceries", remindersListID: UUID(1)) } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(2))), - .saveRecord(Reminder.recordID(for: UUID(3))), - .saveRecord(Reminder.recordID(for: UUID(4))), - ]) - - try database.read { db in + + await syncEngine.processBatch() + + try await database.read { db in let reminderMetadata = try SyncMetadata .where { $0.parentRecordPrimaryKey == UUID(1) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index cc9bacb9..6501daec 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -18,8 +18,45 @@ extension BaseCloudKitTests { ) } - @Test(.snapshots(record: .missing)) + @Test func initialSync() async throws { + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Write blog post", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + let metadata = try await database.read { db in try SyncMetadata.all.order(by: \.primaryKey).fetchAll(db) } @@ -36,7 +73,12 @@ extension BaseCloudKitTests { recordType: "remindersLists", id: UUID(00000000-0000-0000-0000-000000000001) ), - lastKnownServerRecord: nil, + lastKnownServerRecord: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil + ), share: nil, userModificationDate: Date(2009-02-13T23:31:30.000Z) ), @@ -47,64 +89,18 @@ extension BaseCloudKitTests { id: UUID(00000000-0000-0000-0000-000000000001) ), parentRecordName: nil, - lastKnownServerRecord: nil, + lastKnownServerRecord: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil + ), share: nil, userModificationDate: Date(2009-02-13T23:31:30.000Z) ) ] """ } - let batch = await syncEngine.nextRecordZoneChangeBatch(syncEngine: syncEngine.private) - assertInlineSnapshot(of: batch, as: .customDump) { - """ - CKSyncEngine.RecordZoneChangeBatch( - atomicByZone: false, - recordIDsToDelete: [], - recordsToSave: [ - [0]: CKRecord( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:reminders", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ), - recordType: "reminders", - share: nil, - parent: CKReference( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ), - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - title: "Write blog post" - ), - [1]: CKRecord( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ), - recordType: "remindersLists", - share: nil, - parent: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - title: "Personal" - ) - ] - ) - """ - } } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index d4cd1fe0..3abdcdc4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -14,18 +14,18 @@ extension BaseCloudKitTests { pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: UUID(1)))] ) - let batch = await syncEngine.nextRecordZoneChangeBatch( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([Reminder.recordID(for: UUID(1))]) - ), - syncEngine: syncEngine.private - ) - assertInlineSnapshot(of: batch, as: .customDump) { - """ - CKSyncEngine.RecordZoneChangeBatch( - atomicByZone: false, - recordIDsToDelete: [], - recordsToSave: [] + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) """ } @@ -41,37 +41,19 @@ extension BaseCloudKitTests { } .execute(db) } - assertInlineSnapshot(of: syncEngine.private.state, as: .customDump) { - """ - MockSyncEngineState( - pendingRecordZoneChanges: [ - [0]: .saveRecord( - CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:unrecognizedTables", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ) - ], - pendingDatabaseChanges: [] - ) - """ - } - let batch = await syncEngine.nextRecordZoneChangeBatch( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([UnrecognizedTable.recordID(for: UUID(1))]) - ), - syncEngine: syncEngine.private - ) - assertInlineSnapshot(of: batch, as: .customDump) { - """ - CKSyncEngine.RecordZoneChangeBatch( - atomicByZone: false, - recordIDsToDelete: [], - recordsToSave: [] + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) """ } @@ -87,37 +69,19 @@ extension BaseCloudKitTests { } .execute(db) } - assertInlineSnapshot(of: syncEngine.private.state, as: .customDump) { - """ - MockSyncEngineState( - pendingRecordZoneChanges: [ - [0]: .saveRecord( - CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ) - ], - pendingDatabaseChanges: [] - ) - """ - } - let batch = await syncEngine.nextRecordZoneChangeBatch( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) - ), - syncEngine: syncEngine.private - ) - assertInlineSnapshot(of: batch, as: .customDump) { - """ - CKSyncEngine.RecordZoneChangeBatch( - atomicByZone: false, - recordIDsToDelete: [], - recordsToSave: [] + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) """ } @@ -129,59 +93,35 @@ extension BaseCloudKitTests { RemindersList(id: UUID(1), title: "Personal") } } - assertInlineSnapshot(of: syncEngine.private.state, as: .customDump) { - """ - MockSyncEngineState( - pendingRecordZoneChanges: [ - [0]: .saveRecord( - CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ) - ], - pendingDatabaseChanges: [] - ) - """ - } - let batch = await syncEngine.nextRecordZoneChangeBatch( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([RemindersList.recordID(for: UUID(1))]) - ), - syncEngine: syncEngine.private - ) - assertInlineSnapshot(of: batch, as: .customDump) { - """ - CKSyncEngine.RecordZoneChangeBatch( - atomicByZone: false, - recordIDsToDelete: [], - recordsToSave: [ - [0]: CKRecord( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ), - recordType: "remindersLists", - share: nil, - parent: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - title: "Personal" - ) - ] + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) """ } } - @Test(.snapshots(record: .missing)) + @Test func saveRecordWithParent() async throws { try await database.asyncWrite { db in try db.seed { @@ -189,90 +129,40 @@ extension BaseCloudKitTests { Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) } } - assertInlineSnapshot(of: syncEngine.private.state, as: .customDump) { - """ - MockSyncEngineState( - pendingRecordZoneChanges: [ - [0]: .saveRecord( - CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:reminders", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ), - [1]: .saveRecord( - CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ) - ], - pendingDatabaseChanges: [] - ) - """ - } - let batch = await syncEngine.nextRecordZoneChangeBatch( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([ - RemindersList.recordID(for: UUID(1)), - Reminder.recordID(for: UUID(1)), - ]) - ), - syncEngine: syncEngine.private - ) - assertInlineSnapshot(of: batch, as: .customDump) { - """ - CKSyncEngine.RecordZoneChangeBatch( - atomicByZone: false, - recordIDsToDelete: [], - recordsToSave: [ - [0]: CKRecord( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:reminders", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ), - recordType: "reminders", - share: nil, - parent: CKReference( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ), - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Get milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) ), - recordType: "remindersLists", - share: nil, - parent: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - title: "Personal" - ) - ] + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) """ } @@ -285,81 +175,39 @@ extension BaseCloudKitTests { RemindersListPrivate(id: UUID(1), position: 42, remindersListID: UUID(1)) } } - assertInlineSnapshot(of: syncEngine.private.state, as: .customDump) { - """ - MockSyncEngineState( - pendingRecordZoneChanges: [ - [0]: .saveRecord( - CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersListPrivates", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ), - [1]: .saveRecord( - CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ) - ) - ], - pendingDatabaseChanges: [] - ) - """ - } - let batch = await syncEngine.nextRecordZoneChangeBatch( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([ - RemindersList.recordID(for: UUID(1)), - Reminder.recordID(for: UUID(1)), - ]) - ), - syncEngine: syncEngine.private - ) - assertInlineSnapshot(of: batch, as: .customDump) { - """ - CKSyncEngine.RecordZoneChangeBatch( - atomicByZone: false, - recordIDsToDelete: [], - recordsToSave: [ - [0]: CKRecord( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersListPrivates", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListPrivates/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersListPrivates", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + position: 42, + remindersListID: "00000000-0000-0000-0000-000000000001", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) ), - recordType: "remindersListPrivates", - share: nil, - parent: nil, - id: "00000000-0000-0000-0000-000000000001", - position: 42, - remindersListID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) - ), - [1]: CKRecord( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "co.pointfree.SQLiteData.defaultZone", - ownerName: "__defaultOwner__" - ) - ), - recordType: "remindersLists", - share: nil, - parent: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - title: "Personal" - ) - ] + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) """ } diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 557570cd..9c317968 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -53,6 +53,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "isCompleted" INTEGER NOT NULL DEFAULT 0, "title" TEXT NOT NULL DEFAULT '', "remindersListID" TEXT NOT NULL, @@ -169,6 +170,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "isCompleted" INTEGER NOT NULL DEFAULT 0, "title" TEXT NOT NULL DEFAULT '', "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index f129a660..f5e7fbb6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -21,11 +21,8 @@ extension BaseCloudKitTests { user } } - syncEngine.private.state.assertPendingRecordZoneChanges([ - .saveRecord(RemindersList.recordID(for: UUID(1))), - .saveRecord(Reminder.recordID(for: UUID(1))), - .saveRecord(User.recordID(for: UUID(1))), - ]) + + await syncEngine.processBatch() await #expect(throws: SyncEngine.RecordMustBeRoot.self) { _ = try await self.syncEngine.share(record: reminder, configure: { _ in }) @@ -80,11 +77,8 @@ extension BaseCloudKitTests { remindersListRecord.encryptedValues["isCompleted"] = false remindersListRecord.encryptedValues["title"] = "Personal" remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1_234_567_890) - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [remindersListRecord], - deletions: [], - syncEngine: syncEngine.private - ) + + await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) try await database.asyncWrite { db in try db.seed { @@ -92,43 +86,41 @@ extension BaseCloudKitTests { } } - let batch = await syncEngine.nextRecordZoneChangeBatch( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) - ), - syncEngine: syncEngine.shared - ) - assertInlineSnapshot(of: batch, as: .customDump) { + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ - CKSyncEngine.RecordZoneChangeBatch( - atomicByZone: false, - recordIDsToDelete: [], - recordsToSave: [ - [0]: CKRecord( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:reminders", - zoneID: CKRecordZoneID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - ), - recordType: "reminders", - share: nil, - parent: CKReference( - recordID: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", - zoneID: CKRecordZoneID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - ) - ), - id: "00000000-0000-0000-0000-000000000001", - remindersListID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - title: "Get milk" - ) - ] + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: 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: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Get milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ) ) """ } @@ -157,38 +149,35 @@ extension BaseCloudKitTests { reminderRecord.encryptedValues["title"] = "Get milk" reminderRecord.encryptedValues["remindersListID"] = UUID(1).uuidString.lowercased() remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1_234_567_890) - await syncEngine.handleFetchedRecordZoneChanges( - modifications: [ - remindersListRecord, - reminderRecord - ], - syncEngine: syncEngine.private - ) + + await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) try await database.asyncWrite { db in try Reminder.find(UUID(1)).delete().execute(db) } - let batch = await syncEngine.nextRecordZoneChangeBatch( - options: CKSyncEngine.SendChangesOptions( - scope: .recordIDs([Reminder.recordID(for: UUID(1), zoneID: externalZoneID)]) - ), - syncEngine: syncEngine.shared - ) - assertInlineSnapshot(of: batch, as: .customDump) { + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ - CKSyncEngine.RecordZoneChangeBatch( - atomicByZone: false, - recordIDsToDelete: [ - [0]: CKRecordID( - recordName: "00000000-0000-0000-0000-000000000001:reminders", - zoneID: CKRecordZoneID( - zoneName: "external.zone", - ownerName: "external.owner" + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) ) - ) - ], - recordsToSave: [] + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) """ } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index a57e7c02..f2dde7f0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -21,7 +21,8 @@ extension BaseCloudKitTests { reminder } } - _ = await syncEngine.nextRecordZoneChangeBatch(syncEngine: syncEngine.private) + + await syncEngine.processBatch() let personalListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: UUID(1)) diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 146d5795..63721e18 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,7 @@ import SnapshotTesting import Testing @Suite( - .snapshots(record: .missing), + .snapshots(record: .failed), .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)) ) class BaseCloudKitTests: @unchecked Sendable { diff --git a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift index a42a7e6a..962f2495 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift @@ -1,77 +1,105 @@ -import CloudKit -import CustomDump +#if canImport(CloudKit) + import CustomDump + import CloudKit + import SharingGRDB -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension CKRecord: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - return Mirror( - self, - children: [ - ("recordID", recordID as Any), - ("recordType", recordType as Any), - ("share", share as Any), - ("parent", parent as Any), - ] + self.encryptedValues.allKeys().sorted().map { - ($0, self.encryptedValues[$0] as Any) - }, - displayStyle: .struct - ) + extension CKDatabase.Scope: @retroactive CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .public: + ".public" + case .private: + ".private" + case .shared: + ".shared" + @unknown default: + "@unknown" + } + } } -} -extension CKRecord.Reference: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - return Mirror( - self, - children: [ - ("recordID", recordID as Any), - ], - displayStyle: .struct - ) + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKRecord: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + let keys = encryptedValues.allKeys() + .sorted { lhs, rhs in + ( + lhs.hasPrefix("\(String.sqliteDataCloudKitSchemaName)_userModificationDate") ? 1 : 0, + lhs + ) < ( + rhs.hasPrefix("\(String.sqliteDataCloudKitSchemaName)_userModificationDate") ? 1 : 0, + rhs + ) + } + return Mirror( + self, + children: [ + ("recordID", recordID as Any), + ("recordType", recordType as Any), + ("parent", parent as Any), + ("share", share as Any), + ] + + keys + .map { + ($0, self.encryptedValues[$0] as Any) + }, + displayStyle: .struct + ) + } } -} -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -extension CKSyncEngine.RecordZoneChangeBatch: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - Mirror( - self, - children: [ - ("atomicByZone", atomicByZone as Any), - ("recordIDsToDelete", recordIDsToDelete.sorted { lhs, rhs in - lhs.recordName < rhs.recordName - } as Any), - ("recordsToSave", recordsToSave.sorted { lhs, rhs in - lhs.recordID.recordName < rhs.recordID.recordName - } as Any), - ], - displayStyle: .struct - ) + extension CKRecord.Reference: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + return Mirror( + self, + children: [ + ("recordID", recordID as Any) + ], + displayStyle: .struct + ) + } } -} -extension CKRecord.ID: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - Mirror( - self, - children: [ - "recordName": recordName, - "zoneID": zoneID, - ], - displayStyle: .struct - ) + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKSyncEngine.RecordZoneChangeBatch: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + Mirror( + self, + children: [ + ("atomicByZone", atomicByZone as Any), + ( + "recordIDsToDelete", + recordIDsToDelete.sorted { lhs, rhs in + lhs.recordName < rhs.recordName + } as Any + ), + ( + "recordsToSave", + recordsToSave.sorted { lhs, rhs in + lhs.recordID.recordName < rhs.recordID.recordName + } as Any + ), + ], + displayStyle: .struct + ) + } } -} -extension CKRecordZone.ID: @retroactive CustomDumpReflectable { - public var customDumpMirror: Mirror { - Mirror( - self, - children: [ - "zoneName": zoneName, - "ownerName": ownerName, - ], - displayStyle: .struct - ) + extension CKRecord.ID: @retroactive CustomDumpStringConvertible { + public var customDumpDescription: String { + """ + CKRecord.ID(\ + \(recordName.replacingOccurrences(of: "^[0-]+", with: "", options: .regularExpression))/\ + \(zoneID.zoneName)/\ + \(zoneID.ownerName)\ + ) + """ + } } -} + + extension CKRecordZone.ID: @retroactive CustomDumpStringConvertible { + public var customDumpDescription: String { + "CKRecordZone.ID(\(zoneName)/\(ownerName))" + } + } +#endif diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index cb7d83c6..b26de2f1 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -69,58 +69,8 @@ final class MockSyncEngine: SyncEngineProtocol { fatalError() } } - let (saveResults, deleteResults) = database.modifyRecords( - saving: recordsToSave, - deleting: recordIDsToDelete, - savePolicy: .ifServerRecordUnchanged, - atomically: true - ) - var savedRecords: [CKRecord] = [] - var failedRecordSaves: [(record: CKRecord, error: CKError)] = [] - var deletedRecordIDs: [CKRecord.ID] = [] - var failedRecordDeletes: [CKRecord.ID: CKError] = [:] - for (recordID, result) in saveResults { - switch result { - case .success(let record): - savedRecords.append(record) - case .failure(let error as CKError): - guard let record = recordsToSave.first(where: { $0.recordID == recordID }) - else { fatalError("\(recordID.debugDescription) not found in pending changes") } - failedRecordSaves.append((record: record, error: error)) - case .failure: - fatalError("Mocks should only raise 'CKError' values.") - } - } - for (recordID, result) in deleteResults { - switch result { - case .success: - deletedRecordIDs.append(recordID) - case .failure(let error as CKError): - failedRecordDeletes[recordID] = error - case .failure: - fatalError("Mocks should only raise 'CKError' values.") - } - } - - state.remove( - pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) } - + recordIDsSkipped.map { .saveRecord($0) } - ) - state.remove( - pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } - ) - - await delegate - .handleEvent( - .sentRecordZoneChanges( - savedRecords: savedRecords, - failedRecordSaves: failedRecordSaves, - deletedRecordIDs: deletedRecordIDs, - failedRecordDeletes: failedRecordDeletes - ), - syncEngine: self - ) + state.remove(pendingRecordZoneChanges: recordIDsSkipped.map { .saveRecord($0) }) return CKSyncEngine.RecordZoneChangeBatch( recordsToSave: recordsToSave, @@ -174,9 +124,13 @@ final class MockSyncEngine: SyncEngineProtocol { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectable { - private let _pendingRecordZoneChanges = LockIsolated>([] + private let _pendingRecordZoneChanges = LockIsolated< + OrderedSet + >([] ) - private let _pendingDatabaseChanges = LockIsolated>([]) + private let _pendingDatabaseChanges = LockIsolated< + OrderedSet + >([]) private let fileID: StaticString private let filePath: StaticString private let line: UInt @@ -394,6 +348,7 @@ final class MockCloudDatabase: CloudDatabase { storage[recordIDToDelete] = nil deleteResults[recordIDToDelete] = .success(()) } + return (saveResults: saveResults, deleteResults: deleteResults) } } @@ -417,7 +372,7 @@ extension MockCloudDatabase: CustomDumpReflectable { .value .sorted { ($0.value.recordType, $0.value.recordID.recordName) - < ($1.value.recordType, $1.value.recordID.recordName) + < ($1.value.recordType, $1.value.recordID.recordName) } .map(\.value), ], @@ -451,11 +406,11 @@ final class MockCloudContainer: CloudContainer { @Dependency(\.mockCloudContainers) var mockCloudContainers return mockCloudContainers.withValue { storage in let container = - storage[containerIdentifier] - ?? MockCloudContainer( - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ) + storage[containerIdentifier] + ?? MockCloudContainer( + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ) storage[containerIdentifier] = container // TODO: possible to work around? return container as! Self @@ -521,3 +476,142 @@ private func comparePendingDatabaseChange( false } } + +extension SyncEngine { + func modifyRecords( + scope: CKDatabase.Scope, + saving recordsToSave: [CKRecord] = [], + deleting recordIDsToDelete: [CKRecord.ID] = [] + ) async { + let syncEngine = syncEngine(for: scope) + let recordsToDeleteByID = Dictionary( + grouping: syncEngine.database.storage.withValue { storage in + recordIDsToDelete.compactMap { recordID in storage[recordID] } + }, + by: \.recordID + ) + .compactMapValues(\.first) + + let (saveResults, deleteResults) = syncEngine.database.modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete + ) + + await syncEngine.delegate.handleEvent( + .fetchedRecordZoneChanges( + modifications: saveResults.values.compactMap { try? $0.get() }, + deletions: deleteResults.compactMap { recordID, result in + syncEngine.database.storage.withValue { storage in + (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in + (try? result.get()) != nil + ? (recordID, recordType) + : nil + } + } + } + ), + syncEngine: syncEngine + ) + } + + func processBatch( + options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(), + scope: CKDatabase.Scope? = nil + ) async { + guard let scope + else { + await processBatch(options: options, scope: .private) + await processBatch(options: options, scope: .shared) + return + } + + let syncEngine = syncEngine(for: scope) + + let batch = await nextRecordZoneChangeBatch( + reason: .scheduled, + options: options, + syncEngine: { + switch scope { + case .private: + self.private + case .shared: + self.shared + case .public: + fatalError("Public database not supported in tests.") + @unknown default: + fatalError("Unknown database scope not supported in tests.") + } + }() + ) + guard let batch + else { return } + + let (saveResults, deleteResults) = syncEngine.database.modifyRecords( + saving: batch.recordsToSave, + deleting: batch.recordIDsToDelete, + savePolicy: .ifServerRecordUnchanged, + atomically: true + ) + + var savedRecords: [CKRecord] = [] + var failedRecordSaves: [(record: CKRecord, error: CKError)] = [] + var deletedRecordIDs: [CKRecord.ID] = [] + var failedRecordDeletes: [CKRecord.ID: CKError] = [:] + for (recordID, result) in saveResults { + switch result { + case .success(let record): + savedRecords.append(record) + case .failure(let error as CKError): + guard let record = batch.recordsToSave.first(where: { $0.recordID == recordID }) + else { fatalError("\(recordID.debugDescription) not found in pending changes") } + failedRecordSaves.append((record: record, error: error)) + case .failure: + fatalError("Mocks should only raise 'CKError' values.") + } + } + for (recordID, result) in deleteResults { + switch result { + case .success: + deletedRecordIDs.append(recordID) + case .failure(let error as CKError): + failedRecordDeletes[recordID] = error + case .failure: + fatalError("Mocks should only raise 'CKError' values.") + } + } + syncEngine.state.remove( + pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) } + ) + syncEngine.state.remove( + pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } + ) + + await syncEngine.delegate + .handleEvent( + .sentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes + ), + syncEngine: syncEngine + ) + + if !syncEngine.state.pendingRecordZoneChanges.isEmpty { + fatalError("Should we add the option to immediately process any enqueued changes?") + } + } + + private func syncEngine(for scope: CKDatabase.Scope) -> MockSyncEngine { + switch scope { + case .public: + fatalError("Public database not supported in tests.") + case .private: + `private` + case .shared: + shared + @unknown default: + fatalError("Unknown database scope not supported in tests.") + } + } +} From ff066f24e99ef9fd348f5985207bcc46a9c4f45d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 28 Jun 2025 19:48:14 -0700 Subject: [PATCH 248/581] wip --- Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 25318b5a..548dddf1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -469,6 +469,8 @@ extension BaseCloudKitTests { record.encryptedValues["title"] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) record.userModificationDate = serverModificationDate + // NB: Manually setting '_recordChangeTag' simulates another devices saving a record. + record._recordChangeTag = UUID().uuidString _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( @@ -484,7 +486,6 @@ extension BaseCloudKitTests { } ) #expect(metadata.userModificationDate == userModificationDate) - // TODO: The title below should be work. assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ MockCloudContainer( @@ -497,8 +498,8 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Work", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:30:30.000Z) + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) ) ] ), From 16bd0d2af580315828712d650950825307b7ea98 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 28 Jun 2025 19:55:15 -0700 Subject: [PATCH 249/581] wip --- .../SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 316de58b..cf133903 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -28,6 +28,7 @@ extension CKRecord { throw DecodingError() } /* + TODO: Find a workaround for this *** -[NSKeyedUnarchiver validateAllowedClass:forKey:] allowed unarchiving safe plist type ''NSString' (0x1f14d83b0) [/System/Library/Frameworks/Foundation.framework]' for key '_recordChangeTag', even though it was not explicitly included in the client allowed classes set: '{( )}'. This will be disallowed in the future. */ From bdc192900aebc01ccf33ab5b3fe5061918441adf Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 28 Jun 2025 20:11:42 -0700 Subject: [PATCH 250/581] fixed priority inversion --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 999ca711..76b3b6b0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -444,7 +444,7 @@ let deletedRecordIDs, let failedRecordDeletes ): - handleSentRecordZoneChanges( + await handleSentRecordZoneChanges( savedRecords: savedRecords, failedRecordSaves: failedRecordSaves, deletedRecordIDs: deletedRecordIDs, @@ -546,7 +546,7 @@ guard let recordName = SyncMetadata.RecordName(recordID: recordID), - let metadata = metadataFor(recordName: recordName) + let metadata = await metadataFor(recordName: recordName) else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) return nil @@ -593,7 +593,7 @@ with: T(queryOutput: row), userModificationDate: metadata.userModificationDate ) - refreshLastKnownServerRecord(record) + await refreshLastKnownServerRecord(record) sentRecord = recordID return record } @@ -700,7 +700,7 @@ } } else { upsertFromServerRecord(record) - refreshLastKnownServerRecord(record) + await refreshLastKnownServerRecord(record) } if let shareReference = record.share, // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in @@ -755,9 +755,9 @@ deletedRecordIDs: [CKRecord.ID] = [], failedRecordDeletes: [CKRecord.ID: CKError] = [:], syncEngine: any SyncEngineProtocol - ) { + ) async { for savedRecord in savedRecords { - refreshLastKnownServerRecord(savedRecord) + await refreshLastKnownServerRecord(savedRecord) } var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] @@ -791,7 +791,7 @@ guard let serverRecord = failedRecordSave.error.serverRecord else { continue } // TODO: do per-field merging here upsertFromServerRecord(serverRecord) - refreshLastKnownServerRecord(serverRecord) + await refreshLastKnownServerRecord(serverRecord) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) case .zoneNotFound: @@ -817,6 +817,10 @@ } } // TODO: handle event.failedRecordDeletes ? look at apple sample code + + if !failedRecordDeletes.isEmpty { + print("!!!!") + } } private func cacheShare(_ share: CKShare) async throws { @@ -961,13 +965,13 @@ } } - private func refreshLastKnownServerRecord(_ record: CKRecord) { - Self.$isUpdatingWithServerRecord.withValue(true) { + private func refreshLastKnownServerRecord(_ record: CKRecord) async { + await Self.$isUpdatingWithServerRecord.withValue(true) { guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) else { return } - let metadata = metadataFor(recordName: recordName) + let metadata = await metadataFor(recordName: recordName) func updateLastKnownServerRecord() { withErrorReporting(.sqliteDataCloudKitFailure) { @@ -990,9 +994,9 @@ } } - private func metadataFor(recordName: SyncMetadata.RecordName) -> SyncMetadata? { - withErrorReporting(.sqliteDataCloudKitFailure) { - try metadatabase.read { db in + private func metadataFor(recordName: SyncMetadata.RecordName) async -> SyncMetadata? { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await metadatabase.read { db in try SyncMetadata.find(recordName).fetchOne(db) } } From d2aa8658f4e7e53994d6e6f37c9c3268c5733a72 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 28 Jun 2025 20:51:04 -0700 Subject: [PATCH 251/581] debugging --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 45bf498c..8cfd5e24 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -341,6 +341,7 @@ } func didDelete(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { + print("didDelete", recordName) let zoneID = zoneID ?? Self.defaultZone.zoneID let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -477,11 +478,23 @@ } } // TODO: why did we do this again? can we test it? + // TODO: this needs a topological sort to make sure we delete all leaf nodes before parents allChangesByIsDeleted[true]?.reverse() let changes = allChangesByIsDeleted.reduce(into: []) { changes, keyValue in changes += keyValue.value } + print("didDelete", "pendingDeletes", allChangesByIsDeleted[true]?.compactMap { x -> String? in + switch x { + case .saveRecord(_): + return nil + case .deleteRecord(let record): + return String(record.recordName.split(separator: ":")[1]) + @unknown default: + return nil + } + }) + #if DEBUG struct State { var missingTables: [CKRecord.ID] = [] @@ -814,6 +827,9 @@ } } // TODO: handle event.failedRecordDeletes ? look at apple sample code + if !failedRecordDeletes.isEmpty { + print("?!?!?!!?!") + } } private func cacheShare(_ share: CKShare) async throws { From ac0fa29aab1da2e8ee79000ecfa633eaf0923149 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 29 Jun 2025 20:05:29 -0700 Subject: [PATCH 252/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 8cfd5e24..fd60d53e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1111,14 +1111,16 @@ extension URL { package static func metadatabase(containerIdentifier: String?) throws -> Self { @Dependency(\.context) var context - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - let base: URL = - context == .live - ? .applicationSupportDirectory - : .temporaryDirectory + let base: URL + if context == .live { + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true + ) + base = .applicationSupportDirectory + } else { + base = .temporaryDirectory + } return base.appending( component: "\(containerIdentifier.map { "\($0)." } ?? "")sqlite-data-icloud.sqlite" ) From 96fab797fe931e343ccde50581da3510183f65a8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 29 Jun 2025 21:08:46 -0700 Subject: [PATCH 253/581] Topological sort when deleting records. --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 121 ++++++++++++++---- 1 file changed, 99 insertions(+), 22 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index fd60d53e..a9cc8be0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit import ConcurrencyExtras + import CustomDump import OSLog @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -18,6 +19,7 @@ let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let privateTables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + private let tablesByOrder: [String: Int] let foreignKeysByTableName: [String: [ForeignKey]] let syncEngines = LockIsolated(SyncEngines()) let defaultSyncEngines: @@ -114,9 +116,8 @@ self.database = database self.logger = logger self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) - self.tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)).map( - \.type - ) + self.tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) + .map(\.type) self.privateTables = privateTables self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = Dictionary( @@ -129,6 +130,11 @@ } } ) + tablesByOrder = try SharingGRDBCore.tablesByOrder( + database: database, + tables: tables, + tablesByName: tablesByName + ) } package func setUpSyncEngine() async throws { @@ -470,30 +476,43 @@ guard !allChanges.isEmpty else { return nil } - var allChangesByIsDeleted = Dictionary(grouping: allChanges) { - switch $0 { - case .deleteRecord: true - case .saveRecord: false - @unknown default: false + var recordNamesToSave: [CKRecord.ID] = [] + var recordNamesToDelete: [(tableName: String, recordID: CKRecord.ID)] = [] + for change in allChanges { + switch change { + case .saveRecord(let recordID): + recordNamesToSave.append(recordID) + case .deleteRecord(let recordID): + guard let recordName = SyncMetadata.RecordName(rawValue: recordID.recordName) + else { continue } + recordNamesToDelete.append((recordName.recordType, recordID)) + @unknown default: + continue } } - // TODO: why did we do this again? can we test it? - // TODO: this needs a topological sort to make sure we delete all leaf nodes before parents - allChangesByIsDeleted[true]?.reverse() - let changes = allChangesByIsDeleted.reduce(into: []) { changes, keyValue in - changes += keyValue.value + + recordNamesToDelete.sort { lhs, rhs in + (self.tablesByOrder[lhs.tableName] ?? 0) < (self.tablesByOrder[rhs.tableName] ?? 0) } - print("didDelete", "pendingDeletes", allChangesByIsDeleted[true]?.compactMap { x -> String? in - switch x { - case .saveRecord(_): - return nil - case .deleteRecord(let record): - return String(record.recordName.split(separator: ":")[1]) - @unknown default: - return nil + let changes: [CKSyncEngine.PendingRecordZoneChange] = recordNamesToDelete + .map { _, recordID in .deleteRecord(recordID) } + + recordNamesToSave.map { .saveRecord($0) } + + print( + "didDelete", + "pendingDeletes", + changes.compactMap { x -> String? in + switch x { + case .saveRecord(_): + return nil + case .deleteRecord(let record): + return String(record.recordName.split(separator: ":")[1]) + @unknown default: + return nil + } } - }) + ) #if DEBUG struct State { @@ -1255,6 +1274,9 @@ private struct HashablePrimaryKeyedTableType: Hashable { let type: any PrimaryKeyedTable.Type + init(_ type: any PrimaryKeyedTable.Type) { + self.type = type + } func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(type)) } @@ -1263,4 +1285,59 @@ } } + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + private func tablesByOrder( + database: any DatabaseReader, + tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type], + tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + ) throws -> [String: Int] { + let tableDependencies = try database.read { db in + var dependencies: [HashablePrimaryKeyedTableType: [any PrimaryKeyedTable.Type]] = [:] + for table in tables { + let toTables = try SQLQueryExpression( + """ + SELECT "table" FROM pragma_foreign_key_list(\(quote: table.tableName, delimiter: .text)) + """, + as: String.self + ) + .fetchAll(db) + for toTable in toTables { + guard let toTableType = tablesByName[toTable] + else { continue } + dependencies[HashablePrimaryKeyedTableType(table), default: []].append(toTableType) + } + } + return dependencies + } + + var visited = Set() + var marked = Set() + var result: [String: Int] = [:] + for table in tableDependencies.keys { + try visit(table: table) + } + for (table, order) in result { + result[table] = result.count - order - 1 + } + return result + + func visit(table: HashablePrimaryKeyedTableType) throws { + guard !visited.contains(table) + else { return } + guard !marked.contains(table) + else { + struct CycleError: Error {} + throw CycleError() + } + + marked.insert(table) + for neighbor in tableDependencies[table] ?? [] { + try visit(table: HashablePrimaryKeyedTableType(neighbor)) + } + marked.remove(table) + visited.insert(table) + result[table.type.tableName] = result.count + } + } + #endif From b283e6b38a73b0d003e5d921b09928c2656d2d45 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 30 Jun 2025 10:51:22 -0400 Subject: [PATCH 254/581] wip --- .../CloudKitTests/CloudKitTests.swift | 49 +++++---- .../CloudKitTests/RecordTypeTests.swift | 24 ++--- .../CloudKitTests/SharingTests.swift | 6 -- .../CloudKitTests/TriggerTests.swift | 102 +++++------------- .../Internal/BaseCloudKitTests.swift | 1 - .../Internal/CloudKitTestHelpers.swift | 12 +++ Tests/SharingGRDBTests/Internal/Schema.swift | 34 +++--- 7 files changed, 91 insertions(+), 137 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index b218348a..9a59dc87 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -37,18 +37,6 @@ extension BaseCloudKitTests { """ ), [2]: RecordType( - tableName: "users", - schema: """ - CREATE TABLE "users" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "name" TEXT NOT NULL DEFAULT '', - "parentUserID" TEXT, - - FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE - ) STRICT - """ - ), - [3]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( @@ -60,7 +48,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [4]: RecordType( + [3]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( @@ -69,7 +57,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [5]: RecordType( + [4]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( @@ -79,7 +67,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [6]: RecordType( + [5]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( @@ -87,7 +75,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [7]: RecordType( + [6]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( @@ -96,7 +84,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [8]: RecordType( + [7]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -105,7 +93,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [9]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( @@ -362,9 +350,30 @@ extension BaseCloudKitTests { } #expect(metadata == nil) } + + @Test func cascadingDeletionOrder() async throws { + try await database.asyncWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + Reminder(id: UUID(2), title: "", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "", remindersListID: UUID(1)) + Reminder(id: UUID(4), title: "", remindersListID: UUID(1)) + Tag(id: UUID(1), title: "") + Tag(id: UUID(2), title: "") + ReminderTag(id: UUID(1), reminderID: UUID(1), tagID: UUID(1)) + ReminderTag(id: UUID(2), reminderID: UUID(2), tagID: UUID(1)) + ReminderTag(id: UUID(3), reminderID: UUID(3), tagID: UUID(1)) + ReminderTag(id: UUID(4), reminderID: UUID(4), tagID: UUID(1)) + ReminderTag(id: UUID(5), reminderID: UUID(1), tagID: UUID(2)) + ReminderTag(id: UUID(6), reminderID: UUID(2), tagID: UUID(2)) + ReminderTag(id: UUID(7), reminderID: UUID(3), tagID: UUID(2)) + ReminderTag(id: UUID(8), reminderID: UUID(4), tagID: UUID(2)) + } + } + // TODO: finish test after 'cleanup' branch is merged + } } // TODO: Test what happens when we delete locally and then an edit comes in from the server } - - diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 557570cd..188566c4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -37,18 +37,6 @@ extension BaseCloudKitTests { """ ), [2]: RecordType( - tableName: "users", - schema: """ - CREATE TABLE "users" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "name" TEXT NOT NULL DEFAULT '', - "parentUserID" TEXT, - - FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE - ) STRICT - """ - ), - [3]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( @@ -60,7 +48,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [4]: RecordType( + [3]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( @@ -69,7 +57,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [5]: RecordType( + [4]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( @@ -79,7 +67,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [6]: RecordType( + [5]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( @@ -87,7 +75,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [7]: RecordType( + [6]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( @@ -96,7 +84,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [8]: RecordType( + [7]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -105,7 +93,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [9]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 41f15446..87e8efb4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -12,26 +12,20 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareNonRootRecord() async throws { let reminder = Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) - let user = User(id: UUID(1)) try await database.asyncWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") reminder - user } } privateSyncEngine.state.assertPendingRecordZoneChanges([ .saveRecord(RemindersList.recordID(for: UUID(1))), .saveRecord(Reminder.recordID(for: UUID(1))), - .saveRecord(User.recordID(for: UUID(1))), ]) await #expect(throws: SyncEngine.RecordMustBeRoot.self) { _ = try await self.syncEngine.share(record: reminder, configure: { _ in }) } - await #expect(throws: SyncEngine.RecordMustBeRoot.self) { - _ = try await self.syncEngine.share(record: user, configure: { _ in }) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 8a3af078..96bed313 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -122,14 +122,6 @@ extension BaseCloudKitTests { END """, [12]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_users" - AFTER DELETE ON "users" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'users'); - END - """, - [13]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" AFTER INSERT ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -140,7 +132,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [14]: """ + [13]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -151,7 +143,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [15]: """ + [14]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -162,7 +154,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [16]: """ + [15]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN @@ -173,7 +165,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [17]: """ + [16]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN @@ -184,7 +176,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [18]: """ + [17]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN @@ -195,7 +187,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [19]: """ + [18]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -206,7 +198,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [20]: """ + [19]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -217,7 +209,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [21]: """ + [20]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" AFTER INSERT ON "tags" FOR EACH ROW BEGIN @@ -228,18 +220,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [22]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_users" - AFTER INSERT ON "users" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", sqlitedata_icloud_datetime() - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [23]: """ + [21]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" AFTER UPDATE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -250,7 +231,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [24]: """ + [22]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -261,7 +242,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [25]: """ + [23]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -272,7 +253,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [26]: """ + [24]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -283,7 +264,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [27]: """ + [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -294,7 +275,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [28]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -305,7 +286,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [29]: """ + [27]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -316,7 +297,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [30]: """ + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -327,7 +308,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [31]: """ + [29]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN @@ -338,18 +319,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [32]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_users" - AFTER UPDATE ON "users" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'users', "new"."id" || ':' || 'users', "new"."parentUserID" || ':' || 'users' AS "foreignKey", sqlitedata_icloud_datetime() - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [33]: """ + [30]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" BEFORE DELETE ON "parents" FOR EACH ROW BEGIN @@ -358,7 +328,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [34]: """ + [31]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" BEFORE UPDATE ON "parents" FOR EACH ROW BEGIN @@ -367,7 +337,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [35]: """ + [32]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -376,7 +346,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [36]: """ + [33]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -385,7 +355,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [37]: """ + [34]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -394,7 +364,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [38]: """ + [35]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -403,7 +373,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [39]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_reminders_onDeleteCascade" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN @@ -411,7 +381,7 @@ extension BaseCloudKitTests { WHERE "reminderID" = "old"."id"; END """, - [40]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_tags_onDeleteCascade" AFTER DELETE ON "tags" FOR EACH ROW BEGIN @@ -419,7 +389,7 @@ extension BaseCloudKitTests { WHERE "tagID" = "old"."id"; END """, - [41]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -427,7 +397,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [42]: """ + [39]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -435,24 +405,6 @@ extension BaseCloudKitTests { SET "remindersListID" = "new"."id" WHERE "remindersListID" = "old"."id"; END - """, - [43]: """ - CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onDeleteSetDefault" - AFTER DELETE ON "users" - FOR EACH ROW BEGIN - UPDATE "users" - SET "parentUserID" = NULL - WHERE "parentUserID" = "old"."id"; - END - """, - [44]: """ - CREATE TRIGGER "sqlitedata_icloud_users_belongsTo_users_onUpdateCascade" - AFTER UPDATE ON "users" - FOR EACH ROW BEGIN - UPDATE "users" - SET "parentUserID" = "new"."id" - WHERE "parentUserID" = "old"."id"; - END """ ] """# diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index d81385d4..125b63d3 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -68,7 +68,6 @@ class BaseCloudKitTests: @unchecked Sendable { RemindersList.self, Tag.self, ReminderTag.self, - User.self, Parent.self, ChildWithOnDeleteRestrict.self, ChildWithOnDeleteSetNull.self, diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index b11fa165..b6844752 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -263,11 +263,23 @@ actor MockCloudDatabase: CloudDatabase { saveResults: [CKRecord.ID : Result], deleteResults: [CKRecord.ID : Result] ) { + var deleteResults: [CKRecord.ID : Result] = [:] + for recordToSave in recordsToSave { storage[recordToSave.recordID] = recordToSave } for recordIDToDelete in recordIDsToDelete { + let recordExistsReferencingRecordToDelete = storage.values.contains { record in + record.recordID != recordIDToDelete + && record.parent?.recordID == recordIDToDelete + } + guard !recordExistsReferencingRecordToDelete + else { + deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) + continue + } storage[recordIDToDelete] = nil + deleteResults[recordIDToDelete] = .success(()) } return ( saveResults: Dictionary( diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index dd47f9d5..92675f90 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -24,11 +24,11 @@ import SharingGRDB var reminderID: Reminder.ID var tagID: Tag.ID } -@Table struct User: Equatable, Identifiable { - let id: UUID - var name = "" - var parentUserID: User.ID? -} +//@Table struct User: Equatable, Identifiable { +// let id: UUID +// var name = "" +// var parentUserID: User.ID? +//} @Table struct Parent: Equatable, Identifiable { let id: UUID @@ -78,18 +78,18 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ ) .execute(db) - try #sql( - """ - CREATE TABLE "users" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "name" TEXT NOT NULL DEFAULT '', - "parentUserID" TEXT, - - FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE - ) STRICT - """ - ) - .execute(db) +// try #sql( +// """ +// CREATE TABLE "users" ( +// "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), +// "name" TEXT NOT NULL DEFAULT '', +// "parentUserID" TEXT, +// +// FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE +// ) STRICT +// """ +// ) +// .execute(db) try #sql( """ CREATE TABLE "reminders" ( From 0e406542e442c9fb480b62583e3864470130e14c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 30 Jun 2025 11:59:46 -0400 Subject: [PATCH 255/581] simplify --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 45 +++++-------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index a9cc8be0..2de3a616 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -476,43 +476,25 @@ guard !allChanges.isEmpty else { return nil } - var recordNamesToSave: [CKRecord.ID] = [] - var recordNamesToDelete: [(tableName: String, recordID: CKRecord.ID)] = [] - for change in allChanges { + let changes = allChanges.compactMap { + change -> (change: CKSyncEngine.PendingRecordZoneChange, index: Int)? in switch change { case .saveRecord(let recordID): - recordNamesToSave.append(recordID) + return (change, Int.max) case .deleteRecord(let recordID): - guard let recordName = SyncMetadata.RecordName(rawValue: recordID.recordName) - else { continue } - recordNamesToDelete.append((recordName.recordType, recordID)) + guard + let recordName = SyncMetadata.RecordName(rawValue: recordID.recordName), + let index = tablesByOrder[recordName.recordType] + else { return nil } + return (change, index) @unknown default: - continue + return nil } } - - recordNamesToDelete.sort { lhs, rhs in - (self.tablesByOrder[lhs.tableName] ?? 0) < (self.tablesByOrder[rhs.tableName] ?? 0) + .sorted { lhs, rhs in + lhs.index > rhs.index } - - let changes: [CKSyncEngine.PendingRecordZoneChange] = recordNamesToDelete - .map { _, recordID in .deleteRecord(recordID) } - + recordNamesToSave.map { .saveRecord($0) } - - print( - "didDelete", - "pendingDeletes", - changes.compactMap { x -> String? in - switch x { - case .saveRecord(_): - return nil - case .deleteRecord(let record): - return String(record.recordName.split(separator: ":")[1]) - @unknown default: - return nil - } - } - ) + .map(\.change) #if DEBUG struct State { @@ -1316,9 +1298,6 @@ for table in tableDependencies.keys { try visit(table: table) } - for (table, order) in result { - result[table] = result.count - order - 1 - } return result func visit(table: HashablePrimaryKeyedTableType) throws { From 2e522113d014c37fd05c7de3e6a637bfcdc7c897 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 30 Jun 2025 12:03:37 -0400 Subject: [PATCH 256/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 2de3a616..c3821f37 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -491,9 +491,7 @@ return nil } } - .sorted { lhs, rhs in - lhs.index > rhs.index - } + .sorted { lhs, rhs in lhs.index > rhs.index } .map(\.change) #if DEBUG @@ -1310,8 +1308,8 @@ } marked.insert(table) - for neighbor in tableDependencies[table] ?? [] { - try visit(table: HashablePrimaryKeyedTableType(neighbor)) + for dependency in tableDependencies[table] ?? [] { + try visit(table: HashablePrimaryKeyedTableType(dependency)) } marked.remove(table) visited.insert(table) From 48954f506dd05185a22e66c0c71192f1779efecc Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 30 Jun 2025 13:35:56 -0400 Subject: [PATCH 257/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 1 + Tests/SharingGRDBTests/Internal/Schema.swift | 17 ----------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c3821f37..39f821f0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1303,6 +1303,7 @@ else { return } guard !marked.contains(table) else { + // TODO: Can possibly allow cycles by assigning all elements in the cycle the same level and forcing "DELETE CASCADE" on the relationships. struct CycleError: Error {} throw CycleError() } diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 92675f90..758ac349 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -24,11 +24,6 @@ import SharingGRDB var reminderID: Reminder.ID var tagID: Tag.ID } -//@Table struct User: Equatable, Identifiable { -// let id: UUID -// var name = "" -// var parentUserID: User.ID? -//} @Table struct Parent: Equatable, Identifiable { let id: UUID @@ -78,18 +73,6 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ ) .execute(db) -// try #sql( -// """ -// CREATE TABLE "users" ( -// "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), -// "name" TEXT NOT NULL DEFAULT '', -// "parentUserID" TEXT, -// -// FOREIGN KEY("parentUserID") REFERENCES "users"("id") ON DELETE SET DEFAULT ON UPDATE CASCADE -// ) STRICT -// """ -// ) -// .execute(db) try #sql( """ CREATE TABLE "reminders" ( From 415576e2e44ab2ed56cffdee76e72caa2c975d6d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 30 Jun 2025 15:23:32 -0400 Subject: [PATCH 258/581] wip --- Sources/SharingGRDBCore/CloudKit/CloudContainer.swift | 4 ++-- .../SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift | 1 + Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index a2773009..95eaed17 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -7,7 +7,7 @@ package protocol CloudContainer: AnyObject, Equatable, Hashable, Senda var rawValue: CKContainer { get } var privateCloudDatabase: Database { get } func accept(_ metadata: CKShare.Metadata) async throws -> CKShare - func createContainer(identifier containerIdentifier: String) -> Self + static func createContainer(identifier containerIdentifier: String) -> Self var sharedCloudDatabase: Database { get } @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata @@ -22,7 +22,7 @@ extension CloudContainer { } extension CKContainer: CloudContainer { - package func createContainer(identifier containerIdentifier: String) -> Self { + package static func createContainer(identifier containerIdentifier: String) -> Self { Self(identifier: containerIdentifier) } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index cf133903..87ca5061 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -29,6 +29,7 @@ extension CKRecord { } /* TODO: Find a workaround for this + TODO: see if turning off secure coding gets rid of it *** -[NSKeyedUnarchiver validateAllowedClass:forKey:] allowed unarchiving safe plist type ''NSString' (0x1f14d83b0) [/System/Library/Frameworks/Foundation.framework]' for key '_recordChangeTag', even though it was not explicitly included in the client allowed classes set: '{( )}'. This will be disallowed in the future. */ diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 76b3b6b0..42c0701b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -351,7 +351,7 @@ reportIssue("TODO") return } - let container = container.createContainer(identifier: metadata.containerIdentifier) + let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) // TODO: do something with the CKShare returned? _ = try await container.accept(metadata) try await syncEngines.shared?.fetchChanges( diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index b26de2f1..380cde72 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -402,7 +402,7 @@ final class MockCloudContainer: CloudContainer { fatalError() } - func createContainer(identifier containerIdentifier: String) -> Self { + static func createContainer(identifier containerIdentifier: String) -> Self { @Dependency(\.mockCloudContainers) var mockCloudContainers return mockCloudContainers.withValue { storage in let container = From 95a67cfb02d10c3b0921be4a74155b1ce2e412d5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 30 Jun 2025 16:12:14 -0400 Subject: [PATCH 259/581] clean up --- .../CloudKit/RecordTypeTable.swift | 2 +- .../CloudKit/StateSerializationTable.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 41 ++++++++++--------- .../SyncMetadata+MacroExpansion.swift | 4 +- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift index 9c591a9c..06f34e66 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift @@ -6,7 +6,7 @@ package struct RecordType: Hashable { } // NB: This is generated by inlining the above macro applications. -extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { +extension RecordType: StructuredQueriesCore.Table, PrimaryKeyedTable { public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore .PrimaryKeyedTableDefinition { diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift index 2739d7d5..b2eef7c6 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerializationTable.swift @@ -15,7 +15,7 @@ extension CKDatabase.Scope: @retroactive QueryBindable { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { +extension StateSerialization: StructuredQueriesCore.Table, PrimaryKeyedTable { public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore .PrimaryKeyedTableDefinition { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 39f821f0..b9cfc037 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -16,9 +16,9 @@ let database: any DatabaseWriter let logger: Logger let metadatabase: any DatabaseReader - let tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - let privateTables: [any StructuredQueriesCore.PrimaryKeyedTable.Type] - let tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + let tables: [any PrimaryKeyedTable.Type] + let privateTables: [any PrimaryKeyedTable.Type] + let tablesByName: [String: any PrimaryKeyedTable.Type] private let tablesByOrder: [String: Int] let foreignKeysByTableName: [String: [ForeignKey]] let syncEngines = LockIsolated(SyncEngines()) @@ -476,23 +476,26 @@ guard !allChanges.isEmpty else { return nil } - let changes = allChanges.compactMap { - change -> (change: CKSyncEngine.PendingRecordZoneChange, index: Int)? in - switch change { - case .saveRecord(let recordID): - return (change, Int.max) - case .deleteRecord(let recordID): + let changes = allChanges.sorted { lhs, rhs in + switch (lhs, rhs) { + case (.saveRecord(let lhs), .saveRecord(let rhs)): + return true + case (.deleteRecord(let lhs), .deleteRecord(let rhs)): guard - let recordName = SyncMetadata.RecordName(rawValue: recordID.recordName), - let index = tablesByOrder[recordName.recordType] - else { return nil } - return (change, index) - @unknown default: - return nil + let lhsRecordName = SyncMetadata.RecordName(rawValue: lhs.recordName), + let lhsIndex = tablesByOrder[lhsRecordName.recordType], + let rhsRecordName = SyncMetadata.RecordName(rawValue: rhs.recordName), + let rhsIndex = tablesByOrder[rhsRecordName.recordType] + else { return true } + return lhsIndex > rhsIndex + case (.saveRecord, .deleteRecord): + return false + case (.deleteRecord, .saveRecord): + return true + default: + return true } } - .sorted { lhs, rhs in lhs.index > rhs.index } - .map(\.change) #if DEBUG struct State { @@ -1268,8 +1271,8 @@ @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) private func tablesByOrder( database: any DatabaseReader, - tables: [any StructuredQueriesCore.PrimaryKeyedTable.Type], - tablesByName: [String: any StructuredQueriesCore.PrimaryKeyedTable.Type] + tables: [any PrimaryKeyedTable.Type], + tablesByName: [String: any PrimaryKeyedTable.Type] ) throws -> [String: Int] { let tableDependencies = try database.read { db in var dependencies: [HashablePrimaryKeyedTableType: [any PrimaryKeyedTable.Type]] = [:] diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 92e4494d..973045d3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -3,7 +3,7 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { + public struct TableColumns: StructuredQueriesCore.TableDefinition, PrimaryKeyedTableDefinition { public typealias QueryValue = SyncMetadata public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) @@ -90,7 +90,7 @@ extension SyncMetadata { } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata: StructuredQueriesCore.Table, PrimaryKeyedTable { public static let columns = TableColumns() public static let tableName = "sqlitedata_icloud_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { From fbcaea1dd648f824be8e8aea78a2fbb8830990c8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 30 Jun 2025 16:15:00 -0400 Subject: [PATCH 260/581] fix --- .../CloudKit/CloudKit+StructuredQueries.swift | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 87ca5061..745f45d1 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -8,9 +8,11 @@ extension CKRecord { public let queryOutput: CKRecord public var queryBinding: QueryBinding { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) + let archiver = NSKeyedArchiver(requiringSecureCoding: !isTesting) queryOutput.encodeSystemFields(with: archiver) - archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + if isTesting { + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + } return archiver.encodedData.queryBinding } @@ -23,17 +25,13 @@ extension CKRecord { throw QueryDecodingError.missingRequiredColumn } let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true + coder.requiresSecureCoding = !isTesting guard let queryOutput = CKRecord(coder: coder) else { throw DecodingError() } - /* - TODO: Find a workaround for this - TODO: see if turning off secure coding gets rid of it - *** -[NSKeyedUnarchiver validateAllowedClass:forKey:] allowed unarchiving safe plist type ''NSString' (0x1f14d83b0) [/System/Library/Frameworks/Foundation.framework]' for key '_recordChangeTag', even though it was not explicitly included in the client allowed classes set: '{( - )}'. This will be disallowed in the future. - */ - queryOutput._recordChangeTag = coder.decodeObject(forKey: "_recordChangeTag") as? String + if isTesting { + queryOutput._recordChangeTag = coder.decodeObject(forKey: "_recordChangeTag") as? String + } self.init(queryOutput: queryOutput) } From f3a749ae2f5048f3932162827af70b0e6118097c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 30 Jun 2025 17:34:05 -0400 Subject: [PATCH 261/581] merge fixes --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 6 +- .../CloudKitTests/CloudKitTests.swift | 75 +++++++++++++++---- .../Internal/CloudKitTestHelpers.swift | 7 -- 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 195cb205..6c5e1da6 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -101,8 +101,9 @@ self.database = database self.logger = logger self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) - self.tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) + let tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) .map(\.type) + self.tables = tables self.privateTables = privateTables self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = Dictionary( @@ -620,8 +621,7 @@ package func handleAccountChange( changeType: CKSyncEngine.Event.AccountChange.ChangeType, syncEngine: any SyncEngineProtocol - ) async - { + ) async { switch changeType { case .signIn: syncEngines.withValue { diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 17c81856..d371b2d5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -565,24 +565,71 @@ extension BaseCloudKitTests { @Test func cascadingDeletionOrder() async throws { try await database.asyncWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) - Reminder(id: UUID(2), title: "", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "", remindersListID: UUID(1)) - Reminder(id: UUID(4), title: "", remindersListID: UUID(1)) Tag(id: UUID(1), title: "") Tag(id: UUID(2), title: "") - ReminderTag(id: UUID(1), reminderID: UUID(1), tagID: UUID(1)) - ReminderTag(id: UUID(2), reminderID: UUID(2), tagID: UUID(1)) - ReminderTag(id: UUID(3), reminderID: UUID(3), tagID: UUID(1)) - ReminderTag(id: UUID(4), reminderID: UUID(4), tagID: UUID(1)) - ReminderTag(id: UUID(5), reminderID: UUID(1), tagID: UUID(2)) - ReminderTag(id: UUID(6), reminderID: UUID(2), tagID: UUID(2)) - ReminderTag(id: UUID(7), reminderID: UUID(3), tagID: UUID(2)) - ReminderTag(id: UUID(8), reminderID: UUID(4), tagID: UUID(2)) } } - // TODO: finish test after 'cleanup' branch is merged + for _ in 1...100 { + try await database.asyncWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + RemindersListPrivate(id: UUID(1), position: 1, remindersListID: UUID(1)) + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + Reminder(id: UUID(2), title: "", remindersListID: UUID(1)) + Reminder(id: UUID(3), title: "", remindersListID: UUID(1)) + Reminder(id: UUID(4), title: "", remindersListID: UUID(1)) + ReminderTag(id: UUID(1), reminderID: UUID(1), tagID: UUID(1)) + ReminderTag(id: UUID(2), reminderID: UUID(2), tagID: UUID(1)) + ReminderTag(id: UUID(3), reminderID: UUID(3), tagID: UUID(1)) + ReminderTag(id: UUID(4), reminderID: UUID(4), tagID: UUID(1)) + ReminderTag(id: UUID(5), reminderID: UUID(1), tagID: UUID(2)) + ReminderTag(id: UUID(6), reminderID: UUID(2), tagID: UUID(2)) + ReminderTag(id: UUID(7), reminderID: UUID(3), tagID: UUID(2)) + ReminderTag(id: UUID(8), reminderID: UUID(4), tagID: UUID(2)) + } + } + + await syncEngine.processBatch() + + try await database.asyncWrite { db in + try RemindersList.find(UUID(1)).delete().execute(db) + } + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(2:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000002", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index dfe87665..45dd70d1 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -72,13 +72,6 @@ final class MockSyncEngine: SyncEngineProtocol { state.remove(pendingRecordZoneChanges: recordIDsSkipped.map { .saveRecord($0) }) - _ = database.modifyRecords( - saving: recordsToSave, - deleting: recordIDsToDelete, - savePolicy: .ifServerRecordUnchanged, - atomically: true - ) - return CKSyncEngine.RecordZoneChangeBatch( recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete From 81c90a5801e91733db14b11f51be1a492094a371 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 30 Jun 2025 15:33:53 -0700 Subject: [PATCH 262/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 78 ++++++++++--- .../CloudKitTests/CloudKitTests.swift | 106 ++++++++++++++++++ 2 files changed, 170 insertions(+), 14 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 745f45d1..d71fa6e6 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -1,5 +1,6 @@ #if canImport(CloudKit) import CloudKit +import CryptoKit import CustomDump import StructuredQueriesCore @@ -8,7 +9,7 @@ extension CKRecord { public let queryOutput: CKRecord public var queryBinding: QueryBinding { - let archiver = NSKeyedArchiver(requiringSecureCoding: !isTesting) + let archiver = NSKeyedArchiver(requiringSecureCoding: true) queryOutput.encodeSystemFields(with: archiver) if isTesting { archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") @@ -25,12 +26,14 @@ extension CKRecord { throw QueryDecodingError.missingRequiredColumn } let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = !isTesting + coder.requiresSecureCoding = true guard let queryOutput = CKRecord(coder: coder) else { throw DecodingError() } if isTesting { - queryOutput._recordChangeTag = coder.decodeObject(forKey: "_recordChangeTag") as? String + queryOutput._recordChangeTag = coder + .decodeObject(of: NSString.self, forKey: "_recordChangeTag") + as? String } self.init(queryOutput: queryOutput) } @@ -96,6 +99,53 @@ extension CKDatabase.Scope { } } +extension CKRecordKeyValueSetting { + package func setValue( + _ newValue: some CKRecordValueProtocol & Equatable, + forKey key: CKRecord.FieldKey, + at userModificationDate: Date? + ) { + if self[key] != newValue { + self[key] = newValue + self[ + "\(String.sqliteDataCloudKitSchemaName)_userModificationDate_\(key)" + ] = userModificationDate + } + } + + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + package func setValue( + _ newValue: [UInt8], + forKey key: CKRecord.FieldKey, + at userModificationDate: Date? + ) { + let hash = SHA256.hash(data: newValue).compactMap { String(format: "%02hhx", $0) }.joined() + let blobURL = URL.temporaryDirectory.appendingPathComponent(hash) + let asset = CKAsset(fileURL: blobURL) + if (self[key] as? CKAsset)?.fileURL != blobURL { + withErrorReporting { + try Data(newValue).write(to: blobURL) + } + self[key] = asset + self[ + "\(String.sqliteDataCloudKitSchemaName)_userModificationDate_\(key)" + ] = userModificationDate + } + } + + package func removeValue( + forKey key: CKRecord.FieldKey, + at userModificationDate: Date? + ) { + if self[key] != nil { + self[key] = nil + self[ + "\(String.sqliteDataCloudKitSchemaName)_userModificationDate_\(key)" + ] = userModificationDate + } + } +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { package func update(with row: T, userModificationDate: Date?) { @@ -106,23 +156,23 @@ extension CKRecord { let value = Value(queryOutput: row[keyPath: column.keyPath]) switch value.queryBinding { case .blob(let value): - let blobURL = URL.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).data") - withErrorReporting { - try Data(value).write(to: blobURL) - } - self[column.name] = CKAsset(fileURL: blobURL) + encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) case .double(let value): - encryptedValues[column.name] = value + encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) case .date(let value): - encryptedValues[column.name] = value + encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) case .int(let value): - encryptedValues[column.name] = value + encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) case .null: - encryptedValues[column.name] = nil + encryptedValues.removeValue(forKey: column.name, at: userModificationDate) case .text(let value): - encryptedValues[column.name] = value + encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) case .uuid(let value): - encryptedValues[column.name] = value.uuidString.lowercased() + encryptedValues.setValue( + value.uuidString.lowercased(), + forKey: column.name, + at: userModificationDate + ) case .invalid(let error): reportIssue(error) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index d371b2d5..e806b22c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -10,6 +10,8 @@ import Testing extension BaseCloudKitTests { @MainActor final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.date.now) var now + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() throws { let zones = try database.write { db in @@ -631,6 +633,110 @@ extension BaseCloudKitTests { } } } + + @Test func merge() async throws { + try await database.asyncWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "") + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let userModificationDate = now.addingTimeInterval(60) + record.encryptedValues.setValue("Buy milk", forKey: "title", at: userModificationDate) + record.userModificationDate = userModificationDate + _ = syncEngine.private.database.modifyRecords(saving: [record]) + + try await database.asyncWrite { db in + try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + } + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Buy milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } // TODO: Test what happens when we delete locally and then an edit comes in from the server From 2372e34e8ab277a196bb1e97cff46e7eb045944d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 10:29:20 -0400 Subject: [PATCH 263/581] update tests --- .../NextRecordZoneChangeBatchTests.swift | 2 +- .../CloudKitTests/TriggerTests.swift | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 3abdcdc4..d7dbd31c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -186,7 +186,7 @@ extension BaseCloudKitTests { [0]: CKRecord( recordID: CKRecord.ID(1:remindersListPrivates/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "remindersListPrivates", - parent: nil, + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", position: 42, diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 96bed313..ca44e929 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -193,7 +193,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", sqlitedata_icloud_datetime() + SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -292,7 +292,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', NULL AS "foreignKey", sqlitedata_icloud_datetime() + SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", sqlitedata_icloud_datetime() ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -390,6 +390,14 @@ extension BaseCloudKitTests { END """, [38]: """ + CREATE TRIGGER "sqlitedata_icloud_remindersListPrivates_belongsTo_remindersLists_onDeleteCascade" + AFTER DELETE ON "remindersLists" + FOR EACH ROW BEGIN + DELETE FROM "remindersListPrivates" + WHERE "remindersListID" = "old"."id"; + END + """, + [39]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -397,7 +405,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [39]: """ + [40]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN From ee7d593b1bd8aafd9118a5d9602f9de95373ea2e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 11:49:16 -0400 Subject: [PATCH 264/581] wip --- .../CloudKit/CloudKitSharing.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 25 ++-- .../Internal/GRDBHelpers.swift | 23 ---- .../Internal/UserDatabase.swift | 122 ++++++++++++++++++ .../CloudKitTests/CloudKitTests.swift | 50 +++---- .../CloudKitTests/ForeignKeyTests.swift | 30 ++--- .../CloudKitTests/MetadataTests.swift | 22 ++-- .../CloudKitTests/NewTableSyncTests.swift | 39 +----- .../NextRecordZoneChangeBatchTests.swift | 10 +- .../CloudKitTests/RecordTypeTests.swift | 14 +- .../CloudKitTests/SharingTests.swift | 6 +- .../CloudKitTests/SyncEngineSetUpTests.swift | 8 +- .../CloudKitTests/TriggerTests.swift | 6 +- Tests/SharingGRDBTests/FetchAllTests.swift | 6 +- Tests/SharingGRDBTests/FetchOneTests.swift | 20 +-- Tests/SharingGRDBTests/FetchTests.swift | 12 +- Tests/SharingGRDBTests/IntegrationTests.swift | 12 +- .../Internal/BaseCloudKitTests.swift | 8 +- 18 files changed, 243 insertions(+), 172 deletions(-) delete mode 100644 Sources/SharingGRDBCore/Internal/GRDBHelpers.swift create mode 100644 Sources/SharingGRDBCore/Internal/UserDatabase.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 4d3d0b5c..e7f92e45 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -91,7 +91,7 @@ extension SyncEngine { saving: [sharedRecord, rootRecord], deleting: [] ) - try await database.asyncWrite { db in + try await database.write { db in try SyncMetadata .find(recordName) .update { $0.share = sharedRecord } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6c5e1da6..2261d10e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -13,7 +13,8 @@ // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates @TaskLocal static var isUpdatingWithServerRecord = false - let database: any DatabaseWriter + // TODO: rename to 'userDatabase' + let database: UserDatabase let logger: Logger package let metadatabase: any DatabaseReader let tables: [any PrimaryKeyedTable.Type] @@ -34,6 +35,7 @@ tables: [any PrimaryKeyedTable.Type], privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { + let userDatabase = UserDatabase(database: database) try self.init( container: container, defaultSyncEngines: { metadatabase, syncEngine in @@ -64,14 +66,14 @@ ) ) }, - database: database, + database: userDatabase, logger: logger, metadatabaseURL: URL.metadatabase(containerIdentifier: container.containerIdentifier), tables: tables, privateTables: privateTables ) _ = try setUpSyncEngine( - database: database, + database: userDatabase, metadatabase: metadatabase ) } @@ -82,7 +84,7 @@ any DatabaseReader, SyncEngine ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), - database: any DatabaseWriter, + database: UserDatabase, logger: Logger, metadatabaseURL: URL, tables: [any PrimaryKeyedTable.Type], @@ -128,7 +130,7 @@ } nonisolated package func setUpSyncEngine( - database: any DatabaseWriter, + database: UserDatabase, metadatabase: any DatabaseReader ) throws -> Task? { try database.write { db in @@ -273,7 +275,7 @@ async let privateCancellation: Void? = syncEngines.private?.cancelOperations() async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() - try await database.asyncWrite { db in + try await database.write { db in for table in self.tables { try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } @@ -285,7 +287,7 @@ db.remove(function: .isUpdatingWithServerRecord) db.remove(function: .datetime) } - try await database.asyncWrite { db in + try await database.write { db in // TODO: Do an `.erase()` + re-migrate try SyncMetadata.delete().execute(db) try RecordType.delete().execute(db) @@ -859,7 +861,7 @@ return } - try await database.asyncWrite { db in + try await database.write { db in try SyncMetadata .find(recordName) .update { $0.share = share } @@ -1175,9 +1177,10 @@ } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func validateSchema( tables: [any PrimaryKeyedTable.Type], - database: any DatabaseReader + database: UserDatabase ) throws { try database.read { db in for table in tables { @@ -1244,9 +1247,9 @@ } } - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func tablesByOrder( - database: any DatabaseReader, + database: UserDatabase, tables: [any PrimaryKeyedTable.Type], tablesByName: [String: any PrimaryKeyedTable.Type] ) throws -> [String: Int] { diff --git a/Sources/SharingGRDBCore/Internal/GRDBHelpers.swift b/Sources/SharingGRDBCore/Internal/GRDBHelpers.swift deleted file mode 100644 index 48c6f6ce..00000000 --- a/Sources/SharingGRDBCore/Internal/GRDBHelpers.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Dependencies -import GRDB - -extension DatabaseWriter { - // NB: The asynchronous 'write' method on 'DatabaseWriter' uses an escaping closure, which means - // task locals are lost when execute database queries. This method propagates certain task - // locals across that escaping closure boundary, which are used in our database triggers. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - package func asyncWrite( - _ updates: @escaping @Sendable (Database) throws -> T - ) async throws -> T { - let currentIsUpdatingWithServerRecord = SyncEngine.isUpdatingWithServerRecord - return try await withEscapedDependencies { dependencies in - try await write { db in - try SyncEngine.$isUpdatingWithServerRecord.withValue(currentIsUpdatingWithServerRecord) { - try dependencies.yield { - try updates(db) - } - } - } - } - } -} diff --git a/Sources/SharingGRDBCore/Internal/UserDatabase.swift b/Sources/SharingGRDBCore/Internal/UserDatabase.swift new file mode 100644 index 00000000..2b0474a9 --- /dev/null +++ b/Sources/SharingGRDBCore/Internal/UserDatabase.swift @@ -0,0 +1,122 @@ +import Dependencies +import GRDB + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package struct UserDatabase { + private let database: any DatabaseWriter + package init(database: any DatabaseWriter) { + self.database = database + } + + var configuration: Configuration { + database.configuration + } + + func write( + _ updates: @escaping @Sendable (Database) throws -> T + ) async throws -> T { + try await withEscapedDependencies { dependencies in + try await database.write { db in + try SyncEngine.$isUpdatingWithServerRecord.withValue(true) { + try dependencies.yield { + try updates(db) + } + } + } + } + } + + func read( + _ updates: @escaping @Sendable (Database) throws -> T + ) async throws -> T { + try await withEscapedDependencies { dependencies in + try await database.read { db in + try SyncEngine.$isUpdatingWithServerRecord.withValue(true) { + try dependencies.yield { + try updates(db) + } + } + } + } + } + + @_disfavoredOverload + func write( + _ updates: (Database) throws -> T + ) throws -> T { + try withEscapedDependencies { dependencies in + try database.write { db in + try SyncEngine.$isUpdatingWithServerRecord.withValue(true) { + try dependencies.yield { + try updates(db) + } + } + } + } + } + + @_disfavoredOverload + func read( + _ updates: (Database) throws -> T + ) throws -> T { + try withEscapedDependencies { dependencies in + try database.read { db in + try SyncEngine.$isUpdatingWithServerRecord.withValue(true) { + try dependencies.yield { + try updates(db) + } + } + } + } + } + + package func userWrite( + _ updates: @escaping @Sendable (Database) throws -> T + ) async throws -> T { + try await withEscapedDependencies { dependencies in + try await database.write { db in + try dependencies.yield { + try updates(db) + } + } + } + } + + package func userRead( + _ updates: @escaping @Sendable (Database) throws -> T + ) async throws -> T { + try await withEscapedDependencies { dependencies in + try await database.read { db in + try dependencies.yield { + try updates(db) + } + } + } + } + + @_disfavoredOverload + package func userWrite( + _ updates: (Database) throws -> T + ) throws -> T { + try withEscapedDependencies { dependencies in + try database.write { db in + try dependencies.yield { + try updates(db) + } + } + } + } + + @_disfavoredOverload + package func userRead( + _ updates: (Database) throws -> T + ) throws -> T { + try withEscapedDependencies { dependencies in + try database.read { db in + try dependencies.yield { + try updates(db) + } + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index d371b2d5..00cfbcd5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -12,7 +12,7 @@ extension BaseCloudKitTests { final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() throws { - let zones = try database.write { db in + let zones = try database.userWrite { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: zones, as: .customDump) { @@ -111,7 +111,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDown() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -142,12 +142,12 @@ extension BaseCloudKitTests { """ } - try await database.asyncWrite { db in + try await database.userWrite { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 1) } try await syncEngine.tearDownSyncEngine() - try await self.database.write { db in + try await self.database.userWrite { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 0) } @@ -158,7 +158,7 @@ extension BaseCloudKitTests { try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -190,7 +190,7 @@ extension BaseCloudKitTests { } let metadata = - try await database.read { db in + try await database.userRead { db in try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) } #expect(metadata != nil) @@ -207,7 +207,7 @@ extension BaseCloudKitTests { as: String.self ) assertInlineSnapshot( - of: try { try database.write { try query.fetchAll($0) } }(), + of: try { try database.userWrite { try query.fetchAll($0) } }(), as: .customDump ) { """ @@ -222,7 +222,7 @@ extension BaseCloudKitTests { try await syncEngine.tearDownSyncEngine() assertInlineSnapshot( - of: try { try database.write { try query.fetchAll($0) } }(), + of: try { try database.userWrite { try query.fetchAll($0) } }(), as: .customDump ) { """ @@ -238,7 +238,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func insertUpdateDelete() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try RemindersList .insert { RemindersList(id: UUID(1), title: "Personal") } .execute(db) @@ -269,7 +269,7 @@ extension BaseCloudKitTests { """ } - try await database.asyncWrite { db in + try await database.userWrite { db in try RemindersList .find(UUID(1)) .update { $0.title = "Work" } @@ -301,7 +301,7 @@ extension BaseCloudKitTests { """ } - try await database.asyncWrite { db in + try await database.userWrite { db in try RemindersList .find(UUID(1)) .delete() @@ -326,7 +326,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordUpdate() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -358,7 +358,7 @@ extension BaseCloudKitTests { } let userModificationDate = try #require( - try await database.read { db in + try await database.userRead { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -373,12 +373,12 @@ extension BaseCloudKitTests { _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + try { try database.userRead { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Work") ) let metadata = try #require( - try await database.read { db in + try await database.userRead { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -413,7 +413,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordUpdateWithOldRecord() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -445,7 +445,7 @@ extension BaseCloudKitTests { } let userModificationDate = try #require( - try await database.asyncWrite { db in + try await database.userWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -462,12 +462,12 @@ extension BaseCloudKitTests { _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( - try { try database.read { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + try { try database.userRead { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Personal") ) let metadata = try #require( - try await database.asyncWrite { db in + try await database.userWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -502,7 +502,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordDeleted() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -537,10 +537,10 @@ extension BaseCloudKitTests { _ = await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]) #expect( - try await database.read { db in try RemindersList.find(UUID(1)).fetchAll(db) } + try await database.userRead { db in try RemindersList.find(UUID(1)).fetchAll(db) } == [] ) - let metadata = try await database.asyncWrite { db in + let metadata = try await database.userWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -563,14 +563,14 @@ extension BaseCloudKitTests { } @Test func cascadingDeletionOrder() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { Tag(id: UUID(1), title: "") Tag(id: UUID(2), title: "") } } for _ in 1...100 { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") RemindersListPrivate(id: UUID(1), position: 1, remindersListID: UUID(1)) @@ -591,7 +591,7 @@ extension BaseCloudKitTests { await syncEngine.processBatch() - try await database.asyncWrite { db in + try await database.userWrite { db in try RemindersList.find(UUID(1)).delete().execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 51e8eeb3..e2fe27e1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -12,7 +12,7 @@ extension BaseCloudKitTests { final class ForeignKeyTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteCascade() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) @@ -68,10 +68,10 @@ extension BaseCloudKitTests { """ } - try await database.asyncWrite { db in + try await database.userWrite { db in try RemindersList.find(UUID(1)).delete().execute(db) } - try await database.read { db in + try await database.userRead { db in try #expect(Reminder.all.fetchAll(db) == []) } @@ -94,7 +94,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteSetNull() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { Parent(id: UUID(1)) ChildWithOnDeleteSetNull(id: UUID(1), parentID: UUID(1)) @@ -135,10 +135,10 @@ extension BaseCloudKitTests { """ } - try await database.asyncWrite { db in + try await database.userWrite { db in try Parent.find(UUID(1)).delete().execute(db) } - try await database.read { db in + try await database.userRead { db in try expectNoDifference( ChildWithOnDeleteSetNull.all.fetchAll(db), [ @@ -175,7 +175,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func updateCascade() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -231,10 +231,10 @@ extension BaseCloudKitTests { """ } - try await database.asyncWrite { db in + try await database.userWrite { db in try RemindersList.find(UUID(1)).update { $0.id = UUID(9) }.execute(db) } - try await database.read { db in + try await database.userRead { db in try expectNoDifference( Reminder.all.fetchAll(db), [ @@ -305,7 +305,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteRestrict() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { Parent(id: UUID(1)) ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) @@ -347,12 +347,12 @@ extension BaseCloudKitTests { } let error = #expect(throws: DatabaseError.self) { - try self.database.write { db in + try self.database.userWrite { db in try Parent.find(UUID(1)).delete().execute(db) } } #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try await database.read { db in + try await database.userRead { db in try expectNoDifference( ChildWithOnDeleteRestrict.all.fetchAll(db), [ @@ -398,7 +398,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func updateRestrict() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { Parent(id: UUID(1)) ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) @@ -440,12 +440,12 @@ extension BaseCloudKitTests { } let error = #expect(throws: DatabaseError.self) { - try self.database.write { db in + try self.database.userWrite { db in try Parent.find(UUID(1)).update { $0.id = UUID(2) }.execute(db) } } #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try await database.read { db in + try await database.userRead { db in try expectNoDifference( ChildWithOnDeleteRestrict.all.fetchAll(db), [ diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 4f184b32..c5613aa0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -12,7 +12,7 @@ extension BaseCloudKitTests { final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func parentRecordName() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") RemindersList(id: UUID(2), title: "Work") @@ -66,7 +66,7 @@ extension BaseCloudKitTests { """ } - try await database.read { db in + try await database.userRead { db in let reminderMetadata = try #require( try SyncMetadata .find(Reminder.recordName(for: UUID(1))) @@ -75,7 +75,7 @@ extension BaseCloudKitTests { #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(1))) } - try await database.asyncWrite { db in + try await database.userWrite { db in try Reminder.find(UUID(1)) .update { $0.remindersListID = UUID(2) } .execute(db) @@ -135,7 +135,7 @@ extension BaseCloudKitTests { } @Test func noParentRecordForRecordsWithMultipleForeignKeys() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) @@ -200,7 +200,7 @@ extension BaseCloudKitTests { """ } - let parentRecordNames = try await database.read { db in + let parentRecordNames = try await database.userRead { db in try SyncMetadata .where { $0.recordType != Reminder.tableName } .select(\.parentRecordName) @@ -210,7 +210,7 @@ extension BaseCloudKitTests { } @Test func recordType() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -221,7 +221,7 @@ extension BaseCloudKitTests { await syncEngine.processBatch() - let reminderMetadata = try await database.read { db in + let reminderMetadata = try await database.userRead { db in try SyncMetadata .where { $0.recordType == Reminder.tableName } .fetchAll(db) @@ -236,7 +236,7 @@ extension BaseCloudKitTests { } @Test func parentRecordType() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -247,7 +247,7 @@ extension BaseCloudKitTests { await syncEngine.processBatch() - try await database.read { db in + try await database.userRead { db in let reminderMetadata = try SyncMetadata .where { $0.parentRecordType == RemindersList.tableName } @@ -263,7 +263,7 @@ extension BaseCloudKitTests { } @Test func parentRecordPrimaryKey() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -274,7 +274,7 @@ extension BaseCloudKitTests { await syncEngine.processBatch() - try await database.read { db in + try await database.userRead { db in let reminderMetadata = try SyncMetadata .where { $0.parentRecordPrimaryKey == UUID(1) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 6501daec..33e677ba 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -26,28 +26,7 @@ extension BaseCloudKitTests { MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Write blog post", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) - ) - ] + storage: [] ), sharedCloudDatabase: MockCloudDatabase( databaseScope: .shared, @@ -57,7 +36,7 @@ extension BaseCloudKitTests { """ } - let metadata = try await database.read { db in + let metadata = try await database.userRead { db in try SyncMetadata.all.order(by: \.primaryKey).fetchAll(db) } assertInlineSnapshot(of: metadata, as: .customDump) { @@ -73,12 +52,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", id: UUID(00000000-0000-0000-0000-000000000001) ), - lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil - ), + lastKnownServerRecord: nil, share: nil, userModificationDate: Date(2009-02-13T23:31:30.000Z) ), @@ -89,12 +63,7 @@ extension BaseCloudKitTests { id: UUID(00000000-0000-0000-0000-000000000001) ), parentRecordName: nil, - lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil - ), + lastKnownServerRecord: nil, share: nil, userModificationDate: Date(2009-02-13T23:31:30.000Z) ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index d7dbd31c..0d3f3abf 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -32,7 +32,7 @@ extension BaseCloudKitTests { } @Test func nonExistentTable() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try SyncMetadata.insert { SyncMetadata( recordType: UnrecognizedTable.tableName, @@ -60,7 +60,7 @@ extension BaseCloudKitTests { } @Test func metadataRowWithNoCorrespondingRecordRow() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try SyncMetadata.insert { SyncMetadata( recordType: RemindersList.tableName, @@ -88,7 +88,7 @@ extension BaseCloudKitTests { } @Test func saveRecord() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -123,7 +123,7 @@ extension BaseCloudKitTests { @Test func saveRecordWithParent() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) @@ -169,7 +169,7 @@ extension BaseCloudKitTests { } @Test func savePrivateRecord() async throws { - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") RemindersListPrivate(id: UUID(1), position: 42, remindersListID: UUID(1)) diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 17480e98..512524e0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -11,7 +11,7 @@ extension BaseCloudKitTests { final class RecordTypeTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() async throws { - let recordTypes = try await database.asyncWrite { db in + let recordTypes = try await database.userWrite { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: recordTypes, as: .customDump) { @@ -110,29 +110,29 @@ extension BaseCloudKitTests { @Test func tearDown() async throws { try await syncEngine.tearDownSyncEngine() - try await database.asyncWrite { db in + try await database.userWrite { db in try #expect(RecordType.all.fetchAll(db) == []) } } @Test func resetUp() async throws { - let recordTypes = try await database.asyncWrite { db in + let recordTypes = try await database.userWrite { db in try RecordType.all.fetchAll(db) } try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - let recordTypesAfterReSetup = try await database.asyncWrite { db in + let recordTypesAfterReSetup = try await database.userWrite { db in try RecordType.all.fetchAll(db) } expectNoDifference(recordTypes, recordTypesAfterReSetup) } @Test func migration() async throws { - let recordTypes = try await database.asyncWrite { db in + let recordTypes = try await database.userWrite { db in try RecordType.order(by: \.tableName).fetchAll(db) } try await syncEngine.tearDownSyncEngine() - try await database.asyncWrite { db in + try await database.userWrite { db in try #sql( """ ALTER TABLE "reminders" ADD COLUMN "newFeature" INTEGER NOT NULL @@ -142,7 +142,7 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - let recordTypesAfterMigration = try await database.asyncWrite { db in + let recordTypesAfterMigration = try await database.userWrite { db in try RecordType.order(by: \.tableName).fetchAll(db) } let remindersTableIndex = try #require( diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index bcf84551..3be781b1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -13,7 +13,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareNonRootRecord() async throws { let reminder = Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") reminder @@ -75,7 +75,7 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) } @@ -147,7 +147,7 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) - try await database.asyncWrite { db in + try await database.userWrite { db in try Reminder.find(UUID(1)).delete().execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index f2dde7f0..7ee52488 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -14,7 +14,7 @@ extension BaseCloudKitTests { let personalList = RemindersList(id: UUID(1), title: "Personal") let businessList = RemindersList(id: UUID(2), title: "Business") let reminder = Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) - try await database.asyncWrite { db in + try await database.userWrite { db in try db.seed { personalList businessList @@ -49,7 +49,7 @@ extension BaseCloudKitTests { atomically: true ) - try await database.asyncWrite { db in + try await database.userWrite { db in try #sql( """ ALTER TABLE "remindersLists" @@ -70,10 +70,10 @@ extension BaseCloudKitTests { let batch = await syncEngine.nextRecordZoneChangeBatch(syncEngine: syncEngine.private) #expect(batch == nil) - let remindersLists = try await database.read { db in + let remindersLists = try await database.userRead { db in try MigratedRemindersList.order(by: \.id).fetchAll(db) } - let reminders = try await database.read { db in + let reminders = try await database.userRead { db in try MigratedReminder.order(by: \.id).fetchAll(db) } expectNoDifference( diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index ca44e929..b51d77d2 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -10,7 +10,7 @@ extension BaseCloudKitTests { final class TriggerTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func triggers() async throws { - let triggersAfterSetUp = try await database.asyncWrite { db in + let triggersAfterSetUp = try await database.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { @@ -419,7 +419,7 @@ extension BaseCloudKitTests { } try await syncEngine.tearDownSyncEngine() - let triggersAfterTearDown = try await database.asyncWrite { db in + let triggersAfterTearDown = try await database.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } assertInlineSnapshot(of: triggersAfterTearDown, as: .customDump) { @@ -429,7 +429,7 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - let triggersAfterReSetUp = try await database.asyncWrite { db in + let triggersAfterReSetUp = try await database.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } expectNoDifference(triggersAfterReSetUp, triggersAfterSetUp) diff --git a/Tests/SharingGRDBTests/FetchAllTests.swift b/Tests/SharingGRDBTests/FetchAllTests.swift index a3bda46d..51569b75 100644 --- a/Tests/SharingGRDBTests/FetchAllTests.swift +++ b/Tests/SharingGRDBTests/FetchAllTests.swift @@ -15,7 +15,7 @@ struct FetchAllTests { @MainActor @Test func concurrency() async throws { let count = 1_000 - try await database.asyncWrite { db in + try await database.write { db in try Record.delete().execute(db) } @@ -24,7 +24,7 @@ struct FetchAllTests { await withThrowingTaskGroup { group in for index in 1...count { group.addTask { - try await database.asyncWrite { db in + try await database.write { db in try Record.insert { Record(id: index) }.execute(db) } } @@ -37,7 +37,7 @@ struct FetchAllTests { await withThrowingTaskGroup { group in for index in 1...(count / 2) { group.addTask { - try await database.asyncWrite { db in + try await database.write { db in try Record.find(index * 2).delete().execute(db) } } diff --git a/Tests/SharingGRDBTests/FetchOneTests.swift b/Tests/SharingGRDBTests/FetchOneTests.swift index e114189d..41cce897 100644 --- a/Tests/SharingGRDBTests/FetchOneTests.swift +++ b/Tests/SharingGRDBTests/FetchOneTests.swift @@ -22,7 +22,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $record.load() } @@ -35,7 +35,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) #expect($record.loadError == nil) @@ -46,7 +46,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) #expect($record.loadError == nil) @@ -57,7 +57,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $record.load() } @@ -76,7 +76,7 @@ import Testing try await $recordDate.load() #expect(recordDate.timeIntervalSince1970 == 42) #expect($recordDate.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $recordDate.load() } @@ -95,7 +95,7 @@ import Testing try await $recordDate.load() #expect(recordDate?.timeIntervalSince1970 == 42) #expect($recordDate.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $recordDate.load() #expect(recordDate?.timeIntervalSince1970 == nil) #expect($recordDate.loadError == nil) @@ -110,7 +110,7 @@ import Testing try await $recordDate.load() #expect(recordDate?.timeIntervalSince1970 == nil) #expect($recordDate.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $recordDate.load() #expect(recordDate?.timeIntervalSince1970 == nil) #expect($recordDate.loadError == nil) @@ -125,7 +125,7 @@ import Testing try await $recordID.load() #expect(recordID == 1) #expect($recordID.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $recordID.load() } @@ -144,7 +144,7 @@ import Testing try await $record.load() #expect(record == Record(id: 1)) #expect($record.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) #expect($record.loadError == nil) @@ -159,7 +159,7 @@ import Testing try await $id.load() #expect(id == nil) #expect($id.loadError == nil) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $id.load() #expect(id == nil) #expect($id.loadError == nil) diff --git a/Tests/SharingGRDBTests/FetchTests.swift b/Tests/SharingGRDBTests/FetchTests.swift index 790dd524..ecfcf946 100644 --- a/Tests/SharingGRDBTests/FetchTests.swift +++ b/Tests/SharingGRDBTests/FetchTests.swift @@ -14,7 +14,7 @@ struct FetchTests { @FetchAll var records: [Record] #expect(records == [Record(id: 1), Record(id: 2), Record(id: 3)]) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $records.load() #expect(records == []) } @@ -23,7 +23,7 @@ struct FetchTests { @FetchAll(Record.where { $0.id > 1 }) var records: [Record] #expect(records == [Record(id: 2), Record(id: 3)]) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $records.load() #expect(records == []) } @@ -32,7 +32,7 @@ struct FetchTests { @FetchOne(Record.where { $0.id > 1 }.count()) var recordsCount = 0 #expect(recordsCount == 2) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $recordsCount.load() #expect(recordsCount == 0) } @@ -42,7 +42,7 @@ struct FetchTests { #expect(record == Record(id: 1)) print(#line) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) } @@ -52,7 +52,7 @@ struct FetchTests { try await $record.load() #expect(record == Record(id: 1)) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $record.load() } @@ -64,7 +64,7 @@ struct FetchTests { @FetchOne(#sql("SELECT * FROM records LIMIT 1")) var record: Record? #expect(record == Record(id: 1)) - try await database.asyncWrite { try Record.delete().execute($0) } + try await database.write { try Record.delete().execute($0) } try await $record.load() #expect(record == nil) } diff --git a/Tests/SharingGRDBTests/IntegrationTests.swift b/Tests/SharingGRDBTests/IntegrationTests.swift index 618873cd..70787c89 100644 --- a/Tests/SharingGRDBTests/IntegrationTests.swift +++ b/Tests/SharingGRDBTests/IntegrationTests.swift @@ -14,19 +14,19 @@ struct IntegrationTests { @FetchAll(SyncUp.where(\.isActive)) var syncUps: [SyncUp] #expect(syncUps == []) - try await database.asyncWrite { db in + try await database.write { db in _ = try SyncUp.insert { SyncUp.Draft(isActive: true, title: "Engineering") } .execute(db) } try await $syncUps.load() #expect(syncUps == [SyncUp(id: 1, isActive: true, title: "Engineering")]) - try await database.asyncWrite { db in + try await database.write { db in _ = try SyncUp.upsert { SyncUp.Draft(id: 1, isActive: false, title: "Engineering") } .execute(db) } try await $syncUps.load() #expect(syncUps == []) - try await database.asyncWrite { db in + try await database.write { db in _ = try SyncUp.upsert { SyncUp.Draft(id: 1, isActive: true, title: "Engineering") } .execute(db) } @@ -39,19 +39,19 @@ struct IntegrationTests { @Fetch(ActiveSyncUps()) var syncUps: [SyncUp] = [] #expect(syncUps == []) - try await database.asyncWrite { db in + try await database.write { db in _ = try SyncUp.insert { SyncUp.Draft(isActive: true, title: "Engineering") } .execute(db) } try await $syncUps.load() #expect(syncUps == [SyncUp(id: 1, isActive: true, title: "Engineering")]) - try await database.asyncWrite { db in + try await database.write { db in _ = try SyncUp.upsert { SyncUp.Draft(id: 1, isActive: false, title: "Engineering") } .execute(db) } try await $syncUps.load() #expect(syncUps == []) - try await database.asyncWrite { db in + try await database.write { db in _ = try SyncUp.upsert { SyncUp.Draft(id: 1, isActive: true, title: "Engineering") } .execute(db) } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 24714a1f..fc8ea2bb 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -11,7 +11,7 @@ import Testing .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)) ) class BaseCloudKitTests: @unchecked Sendable { - let database: any DatabaseWriter + let database: UserDatabase private let _syncEngine: any Sendable @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -26,7 +26,7 @@ class BaseCloudKitTests: @unchecked Sendable { let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" let database = try SharingGRDBTests.database(containerIdentifier: testContainerIdentifier) - self.database = database + self.database = UserDatabase(database: database) try { [seeds] in try database.write { db in try db.seed { seeds } @@ -41,7 +41,7 @@ class BaseCloudKitTests: @unchecked Sendable { ), privateDatabase: privateDatabase, sharedDatabase: sharedDatabase, - database: database, + database: self.database, metadatabaseURL: URL.metadatabase(containerIdentifier: testContainerIdentifier), tables: [ Reminder.self, @@ -86,7 +86,7 @@ extension SyncEngine { container: any CloudContainer, privateDatabase: MockCloudDatabase, sharedDatabase: MockCloudDatabase, - database: any DatabaseWriter, + database: UserDatabase, metadatabaseURL: URL, tables: [any PrimaryKeyedTable.Type], privateTables: [any PrimaryKeyedTable.Type] = [] From 247318cc21f299f68ba564821550177f5e0b7ce7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 11:59:24 -0400 Subject: [PATCH 265/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../CloudKitTests/NewTableSyncTests.swift | 37 +++++++++++++++++-- .../Internal/BaseCloudKitTests.swift | 14 +++---- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 2261d10e..5f122ace 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -207,7 +207,7 @@ else { return nil } withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in + try database.userWrite { db in for (recordType, isNewTable) in recordTypesToFetch { try RecordType .upsert { RecordType.Draft(recordType) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 33e677ba..48127993 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -26,7 +26,28 @@ extension BaseCloudKitTests { MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, - storage: [] + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Write blog post", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + ) + ] ), sharedCloudDatabase: MockCloudDatabase( databaseScope: .shared, @@ -52,7 +73,12 @@ extension BaseCloudKitTests { recordType: "remindersLists", id: UUID(00000000-0000-0000-0000-000000000001) ), - lastKnownServerRecord: nil, + lastKnownServerRecord: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil + ), share: nil, userModificationDate: Date(2009-02-13T23:31:30.000Z) ), @@ -63,7 +89,12 @@ extension BaseCloudKitTests { id: UUID(00000000-0000-0000-0000-000000000001) ), parentRecordName: nil, - lastKnownServerRecord: nil, + lastKnownServerRecord: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil + ), share: nil, userModificationDate: Date(2009-02-13T23:31:30.000Z) ) diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index fc8ea2bb..d29b1808 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -25,13 +25,13 @@ class BaseCloudKitTests: @unchecked Sendable { init(seeds: [any SendablePrimaryKeyedTable] = []) async throws { let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" - let database = try SharingGRDBTests.database(containerIdentifier: testContainerIdentifier) - self.database = UserDatabase(database: database) - try { [seeds] in - try database.write { db in - try db.seed { seeds } - } - }() + let database = UserDatabase( + database: try SharingGRDBTests.database(containerIdentifier: testContainerIdentifier) + ) + self.database = database + try await database.userWrite { db in + try db.seed { seeds } + } let privateDatabase = MockCloudDatabase(databaseScope: .private) let sharedDatabase = MockCloudDatabase(databaseScope: .shared) _syncEngine = try await SyncEngine( From 5104d4531b01587b1448e816d80e42f7485d942c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 12:10:21 -0400 Subject: [PATCH 266/581] wip --- .../CloudKit/CloudKitSharing.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 73 +++++++++---------- .../CloudKitTests/CloudKitTests.swift | 50 ++++++------- .../CloudKitTests/ForeignKeyTests.swift | 30 ++++---- .../CloudKitTests/MetadataTests.swift | 22 +++--- .../CloudKitTests/NewTableSyncTests.swift | 2 +- .../NextRecordZoneChangeBatchTests.swift | 10 +-- .../CloudKitTests/RecordTypeTests.swift | 14 ++-- .../CloudKitTests/SharingTests.swift | 6 +- .../CloudKitTests/SyncEngineSetUpTests.swift | 8 +- .../CloudKitTests/TriggerTests.swift | 6 +- .../Internal/BaseCloudKitTests.swift | 12 +-- 12 files changed, 116 insertions(+), 119 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index e7f92e45..549e7f99 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -91,7 +91,7 @@ extension SyncEngine { saving: [sharedRecord, rootRecord], deleting: [] ) - try await database.write { db in + try await userDatabase.write { db in try SyncMetadata .find(recordName) .update { $0.share = sharedRecord } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5f122ace..8c9a270d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -13,8 +13,7 @@ // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates @TaskLocal static var isUpdatingWithServerRecord = false - // TODO: rename to 'userDatabase' - let database: UserDatabase + let userDatabase: UserDatabase let logger: Logger package let metadatabase: any DatabaseReader let tables: [any PrimaryKeyedTable.Type] @@ -66,14 +65,14 @@ ) ) }, - database: userDatabase, + userDatabase: userDatabase, logger: logger, metadatabaseURL: URL.metadatabase(containerIdentifier: container.containerIdentifier), tables: tables, privateTables: privateTables ) _ = try setUpSyncEngine( - database: userDatabase, + userDatabase: userDatabase, metadatabase: metadatabase ) } @@ -84,23 +83,23 @@ any DatabaseReader, SyncEngine ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), - database: UserDatabase, + userDatabase: UserDatabase, logger: Logger, metadatabaseURL: URL, tables: [any PrimaryKeyedTable.Type], privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { - try validateSchema(tables: tables, database: database) + try validateSchema(tables: tables, userDatabase: userDatabase) // TODO: Explain why / link to documentation? precondition( - !database.configuration.foreignKeysEnabled, + !userDatabase.configuration.foreignKeysEnabled, """ Foreign key support must be disabled to synchronize with CloudKit. """ ) self.container = container self.defaultSyncEngines = defaultSyncEngines - self.database = database + self.userDatabase = userDatabase self.logger = logger self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) let tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) @@ -109,7 +108,7 @@ self.privateTables = privateTables self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = Dictionary( - uniqueKeysWithValues: try database.read { db in + uniqueKeysWithValues: try userDatabase.read { db in try tables.map { table -> (String, [ForeignKey]) in ( table.tableName, @@ -119,21 +118,21 @@ } ) tablesByOrder = try SharingGRDBCore.tablesByOrder( - database: database, + userDatabase: userDatabase, tables: tables, tablesByName: tablesByName ) } package func setUpSyncEngine() async throws { - try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value + try await setUpSyncEngine(userDatabase: userDatabase, metadatabase: metadatabase)?.value } nonisolated package func setUpSyncEngine( - database: UserDatabase, + userDatabase: UserDatabase, metadatabase: any DatabaseReader ) throws -> Task? { - try database.write { db in + try userDatabase.write { db in let hasAttachedMetadatabase: Bool = try SQLQueryExpression( """ @@ -180,7 +179,7 @@ let previousRecordTypes = try metadatabase.read { db in try RecordType.all.fetchAll(db) } - let currentRecordTypes = try database.read { db in + let currentRecordTypes = try userDatabase.read { db in try SQLQueryExpression( """ SELECT "name", "sql" @@ -207,7 +206,7 @@ else { return nil } withErrorReporting(.sqliteDataCloudKitFailure) { - try database.userWrite { db in + try userDatabase.userWrite { db in for (recordType, isNewTable) in recordTypesToFetch { try RecordType .upsert { RecordType.Draft(recordType) } @@ -275,7 +274,7 @@ async let privateCancellation: Void? = syncEngines.private?.cancelOperations() async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() - try await database.write { db in + try await userDatabase.write { db in for table in self.tables { try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } @@ -287,7 +286,7 @@ db.remove(function: .isUpdatingWithServerRecord) db.remove(function: .datetime) } - try await database.write { db in + try await userDatabase.write { db in // TODO: Do an `.erase()` + re-migrate try SyncMetadata.delete().execute(db) try RecordType.delete().execute(db) @@ -300,7 +299,7 @@ public func deleteLocalData() async throws { try await tearDownSyncEngine() withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in + try userDatabase.write { db in for table in tables { func open(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { @@ -578,7 +577,7 @@ func open>(_: T.Type) async -> CKRecord? { let row = withErrorReporting { - try database.read { db in + try userDatabase.read { db in try T.find(recordName.id).fetchOne(db) } } @@ -631,7 +630,7 @@ } for table in tables { withErrorReporting(.sqliteDataCloudKitFailure) { - let recordNames = try database.read { db in + let recordNames = try userDatabase.read { db in func open>(_: T.Type) throws -> [SyncMetadata.RecordName] { try T .select(\.primaryKey) @@ -666,7 +665,7 @@ syncEngine: any SyncEngineProtocol ) { withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in + try userDatabase.write { db in try StateSerialization.upsert { StateSerialization.Draft( scope: syncEngine.database.databaseScope, @@ -686,7 +685,7 @@ // TODO: How to handle this? Self.$isUpdatingWithServerRecord.withValue(true) { withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in + try userDatabase.write { db in for deletion in deletions { // if let table = tablesByName[deletion.zoneID.zoneName] { // func open(_: T.Type) { @@ -720,11 +719,9 @@ await refreshLastKnownServerRecord(record) } if let shareReference = record.share, - // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in - // TODO: could we use 'syncEngine.database' here instead of container? - let shareRecord = try? await container.database(for: shareReference.recordID) - .record(for: shareReference.recordID), - let share = shareRecord as? CKShare + // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in + let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), + let share = shareRecord as? CKShare { await withErrorReporting { try await cacheShare(share) @@ -740,7 +737,7 @@ } func open>(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in + try userDatabase.write { db in try T.find(recordName.id) .delete() .execute(db) @@ -793,7 +790,7 @@ func clearServerRecord() { withErrorReporting { try Self.$isUpdatingWithServerRecord.withValue(true) { - try database.write { db in + try userDatabase.write { db in try SyncMetadata .find(recordName) .update { $0.lastKnownServerRecord = nil } @@ -861,7 +858,7 @@ return } - try await database.write { db in + try await userDatabase.write { db in try SyncMetadata .find(recordName) .update { $0.share = share } @@ -871,7 +868,7 @@ private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { // TODO: more efficient way to do this? - try database.write { db in + try userDatabase.write { db in let metadata = try SyncMetadata .where { $0.share.isNot(nil) } @@ -916,7 +913,7 @@ userModificationDate > serverRecord.userModificationDate ?? .distantPast else { // TODO: This should be fetched early and held onto (like 'ForeignKey') - let columnNames = try database.read { db in + let columnNames = try userDatabase.read { db in try SQLQueryExpression( """ SELECT "name" @@ -965,7 +962,7 @@ reportIssue("???") return } - try database.write { db in + try userDatabase.write { db in try SQLQueryExpression(query).execute(db) try SyncMetadata .insert { @@ -992,7 +989,7 @@ func updateLastKnownServerRecord() { withErrorReporting(.sqliteDataCloudKitFailure) { - try database.write { db in + try userDatabase.write { db in try SyncMetadata .find(recordName) .update { $0.lastKnownServerRecord = record } @@ -1180,9 +1177,9 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func validateSchema( tables: [any PrimaryKeyedTable.Type], - database: UserDatabase + userDatabase: UserDatabase ) throws { - try database.read { db in + try userDatabase.read { db in for table in tables { // // TODO: write tests for this // let columnsWithUniqueConstraints = @@ -1249,11 +1246,11 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func tablesByOrder( - database: UserDatabase, + userDatabase: UserDatabase, tables: [any PrimaryKeyedTable.Type], tablesByName: [String: any PrimaryKeyedTable.Type] ) throws -> [String: Int] { - let tableDependencies = try database.read { db in + let tableDependencies = try userDatabase.read { db in var dependencies: [HashablePrimaryKeyedTableType: [any PrimaryKeyedTable.Type]] = [:] for table in tables { let toTables = try SQLQueryExpression( diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 00cfbcd5..22364e7e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -12,7 +12,7 @@ extension BaseCloudKitTests { final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() throws { - let zones = try database.userWrite { db in + let zones = try userDatabase.userWrite { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: zones, as: .customDump) { @@ -111,7 +111,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDown() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -142,12 +142,12 @@ extension BaseCloudKitTests { """ } - try await database.userWrite { db in + try await userDatabase.userWrite { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 1) } try await syncEngine.tearDownSyncEngine() - try await self.database.userWrite { db in + try await self.userDatabase.userWrite { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 0) } @@ -158,7 +158,7 @@ extension BaseCloudKitTests { try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -190,7 +190,7 @@ extension BaseCloudKitTests { } let metadata = - try await database.userRead { db in + try await userDatabase.userRead { db in try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) } #expect(metadata != nil) @@ -207,7 +207,7 @@ extension BaseCloudKitTests { as: String.self ) assertInlineSnapshot( - of: try { try database.userWrite { try query.fetchAll($0) } }(), + of: try { try userDatabase.userWrite { try query.fetchAll($0) } }(), as: .customDump ) { """ @@ -222,7 +222,7 @@ extension BaseCloudKitTests { try await syncEngine.tearDownSyncEngine() assertInlineSnapshot( - of: try { try database.userWrite { try query.fetchAll($0) } }(), + of: try { try userDatabase.userWrite { try query.fetchAll($0) } }(), as: .customDump ) { """ @@ -238,7 +238,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func insertUpdateDelete() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try RemindersList .insert { RemindersList(id: UUID(1), title: "Personal") } .execute(db) @@ -269,7 +269,7 @@ extension BaseCloudKitTests { """ } - try await database.userWrite { db in + try await userDatabase.userWrite { db in try RemindersList .find(UUID(1)) .update { $0.title = "Work" } @@ -301,7 +301,7 @@ extension BaseCloudKitTests { """ } - try await database.userWrite { db in + try await userDatabase.userWrite { db in try RemindersList .find(UUID(1)) .delete() @@ -326,7 +326,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordUpdate() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -358,7 +358,7 @@ extension BaseCloudKitTests { } let userModificationDate = try #require( - try await database.userRead { db in + try await userDatabase.userRead { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -373,12 +373,12 @@ extension BaseCloudKitTests { _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( - try { try database.userRead { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + try { try userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Work") ) let metadata = try #require( - try await database.userRead { db in + try await userDatabase.userRead { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -413,7 +413,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordUpdateWithOldRecord() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -445,7 +445,7 @@ extension BaseCloudKitTests { } let userModificationDate = try #require( - try await database.userWrite { db in + try await userDatabase.userWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -462,12 +462,12 @@ extension BaseCloudKitTests { _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( - try { try database.userRead { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), + try { try userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), RemindersList(id: UUID(1), title: "Personal") ) let metadata = try #require( - try await database.userWrite { db in + try await userDatabase.userWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -502,7 +502,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordDeleted() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -537,10 +537,10 @@ extension BaseCloudKitTests { _ = await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]) #expect( - try await database.userRead { db in try RemindersList.find(UUID(1)).fetchAll(db) } + try await userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchAll(db) } == [] ) - let metadata = try await database.userWrite { db in + let metadata = try await userDatabase.userWrite { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -563,14 +563,14 @@ extension BaseCloudKitTests { } @Test func cascadingDeletionOrder() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { Tag(id: UUID(1), title: "") Tag(id: UUID(2), title: "") } } for _ in 1...100 { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") RemindersListPrivate(id: UUID(1), position: 1, remindersListID: UUID(1)) @@ -591,7 +591,7 @@ extension BaseCloudKitTests { await syncEngine.processBatch() - try await database.userWrite { db in + try await userDatabase.userWrite { db in try RemindersList.find(UUID(1)).delete().execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index e2fe27e1..5ae760e6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -12,7 +12,7 @@ extension BaseCloudKitTests { final class ForeignKeyTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteCascade() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) @@ -68,10 +68,10 @@ extension BaseCloudKitTests { """ } - try await database.userWrite { db in + try await userDatabase.userWrite { db in try RemindersList.find(UUID(1)).delete().execute(db) } - try await database.userRead { db in + try await userDatabase.userRead { db in try #expect(Reminder.all.fetchAll(db) == []) } @@ -94,7 +94,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteSetNull() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { Parent(id: UUID(1)) ChildWithOnDeleteSetNull(id: UUID(1), parentID: UUID(1)) @@ -135,10 +135,10 @@ extension BaseCloudKitTests { """ } - try await database.userWrite { db in + try await userDatabase.userWrite { db in try Parent.find(UUID(1)).delete().execute(db) } - try await database.userRead { db in + try await userDatabase.userRead { db in try expectNoDifference( ChildWithOnDeleteSetNull.all.fetchAll(db), [ @@ -175,7 +175,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func updateCascade() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -231,10 +231,10 @@ extension BaseCloudKitTests { """ } - try await database.userWrite { db in + try await userDatabase.userWrite { db in try RemindersList.find(UUID(1)).update { $0.id = UUID(9) }.execute(db) } - try await database.userRead { db in + try await userDatabase.userRead { db in try expectNoDifference( Reminder.all.fetchAll(db), [ @@ -305,7 +305,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteRestrict() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { Parent(id: UUID(1)) ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) @@ -347,12 +347,12 @@ extension BaseCloudKitTests { } let error = #expect(throws: DatabaseError.self) { - try self.database.userWrite { db in + try self.userDatabase.userWrite { db in try Parent.find(UUID(1)).delete().execute(db) } } #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try await database.userRead { db in + try await userDatabase.userRead { db in try expectNoDifference( ChildWithOnDeleteRestrict.all.fetchAll(db), [ @@ -398,7 +398,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func updateRestrict() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { Parent(id: UUID(1)) ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) @@ -440,12 +440,12 @@ extension BaseCloudKitTests { } let error = #expect(throws: DatabaseError.self) { - try self.database.userWrite { db in + try self.userDatabase.userWrite { db in try Parent.find(UUID(1)).update { $0.id = UUID(2) }.execute(db) } } #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try await database.userRead { db in + try await userDatabase.userRead { db in try expectNoDifference( ChildWithOnDeleteRestrict.all.fetchAll(db), [ diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index c5613aa0..7c2c034e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -12,7 +12,7 @@ extension BaseCloudKitTests { final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func parentRecordName() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") RemindersList(id: UUID(2), title: "Work") @@ -66,7 +66,7 @@ extension BaseCloudKitTests { """ } - try await database.userRead { db in + try await userDatabase.userRead { db in let reminderMetadata = try #require( try SyncMetadata .find(Reminder.recordName(for: UUID(1))) @@ -75,7 +75,7 @@ extension BaseCloudKitTests { #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(1))) } - try await database.userWrite { db in + try await userDatabase.userWrite { db in try Reminder.find(UUID(1)) .update { $0.remindersListID = UUID(2) } .execute(db) @@ -135,7 +135,7 @@ extension BaseCloudKitTests { } @Test func noParentRecordForRecordsWithMultipleForeignKeys() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) @@ -200,7 +200,7 @@ extension BaseCloudKitTests { """ } - let parentRecordNames = try await database.userRead { db in + let parentRecordNames = try await userDatabase.userRead { db in try SyncMetadata .where { $0.recordType != Reminder.tableName } .select(\.parentRecordName) @@ -210,7 +210,7 @@ extension BaseCloudKitTests { } @Test func recordType() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -221,7 +221,7 @@ extension BaseCloudKitTests { await syncEngine.processBatch() - let reminderMetadata = try await database.userRead { db in + let reminderMetadata = try await userDatabase.userRead { db in try SyncMetadata .where { $0.recordType == Reminder.tableName } .fetchAll(db) @@ -236,7 +236,7 @@ extension BaseCloudKitTests { } @Test func parentRecordType() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -247,7 +247,7 @@ extension BaseCloudKitTests { await syncEngine.processBatch() - try await database.userRead { db in + try await userDatabase.userRead { db in let reminderMetadata = try SyncMetadata .where { $0.parentRecordType == RemindersList.tableName } @@ -263,7 +263,7 @@ extension BaseCloudKitTests { } @Test func parentRecordPrimaryKey() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) @@ -274,7 +274,7 @@ extension BaseCloudKitTests { await syncEngine.processBatch() - try await database.userRead { db in + try await userDatabase.userRead { db in let reminderMetadata = try SyncMetadata .where { $0.parentRecordPrimaryKey == UUID(1) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 48127993..574cb8a1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -57,7 +57,7 @@ extension BaseCloudKitTests { """ } - let metadata = try await database.userRead { db in + let metadata = try await userDatabase.userRead { db in try SyncMetadata.all.order(by: \.primaryKey).fetchAll(db) } assertInlineSnapshot(of: metadata, as: .customDump) { diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 0d3f3abf..a69f9c9b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -32,7 +32,7 @@ extension BaseCloudKitTests { } @Test func nonExistentTable() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try SyncMetadata.insert { SyncMetadata( recordType: UnrecognizedTable.tableName, @@ -60,7 +60,7 @@ extension BaseCloudKitTests { } @Test func metadataRowWithNoCorrespondingRecordRow() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try SyncMetadata.insert { SyncMetadata( recordType: RemindersList.tableName, @@ -88,7 +88,7 @@ extension BaseCloudKitTests { } @Test func saveRecord() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") } @@ -123,7 +123,7 @@ extension BaseCloudKitTests { @Test func saveRecordWithParent() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) @@ -169,7 +169,7 @@ extension BaseCloudKitTests { } @Test func savePrivateRecord() async throws { - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") RemindersListPrivate(id: UUID(1), position: 42, remindersListID: UUID(1)) diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 512524e0..69707f0b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -11,7 +11,7 @@ extension BaseCloudKitTests { final class RecordTypeTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() async throws { - let recordTypes = try await database.userWrite { db in + let recordTypes = try await userDatabase.userWrite { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: recordTypes, as: .customDump) { @@ -110,29 +110,29 @@ extension BaseCloudKitTests { @Test func tearDown() async throws { try await syncEngine.tearDownSyncEngine() - try await database.userWrite { db in + try await userDatabase.userWrite { db in try #expect(RecordType.all.fetchAll(db) == []) } } @Test func resetUp() async throws { - let recordTypes = try await database.userWrite { db in + let recordTypes = try await userDatabase.userWrite { db in try RecordType.all.fetchAll(db) } try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - let recordTypesAfterReSetup = try await database.userWrite { db in + let recordTypesAfterReSetup = try await userDatabase.userWrite { db in try RecordType.all.fetchAll(db) } expectNoDifference(recordTypes, recordTypesAfterReSetup) } @Test func migration() async throws { - let recordTypes = try await database.userWrite { db in + let recordTypes = try await userDatabase.userWrite { db in try RecordType.order(by: \.tableName).fetchAll(db) } try await syncEngine.tearDownSyncEngine() - try await database.userWrite { db in + try await userDatabase.userWrite { db in try #sql( """ ALTER TABLE "reminders" ADD COLUMN "newFeature" INTEGER NOT NULL @@ -142,7 +142,7 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - let recordTypesAfterMigration = try await database.userWrite { db in + let recordTypesAfterMigration = try await userDatabase.userWrite { db in try RecordType.order(by: \.tableName).fetchAll(db) } let remindersTableIndex = try #require( diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 3be781b1..a6db50ad 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -13,7 +13,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareNonRootRecord() async throws { let reminder = Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "Personal") reminder @@ -75,7 +75,7 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) } @@ -147,7 +147,7 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) - try await database.userWrite { db in + try await userDatabase.userWrite { db in try Reminder.find(UUID(1)).delete().execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index 7ee52488..4bf0c18e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -14,7 +14,7 @@ extension BaseCloudKitTests { let personalList = RemindersList(id: UUID(1), title: "Personal") let businessList = RemindersList(id: UUID(2), title: "Business") let reminder = Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) - try await database.userWrite { db in + try await userDatabase.userWrite { db in try db.seed { personalList businessList @@ -49,7 +49,7 @@ extension BaseCloudKitTests { atomically: true ) - try await database.userWrite { db in + try await userDatabase.userWrite { db in try #sql( """ ALTER TABLE "remindersLists" @@ -70,10 +70,10 @@ extension BaseCloudKitTests { let batch = await syncEngine.nextRecordZoneChangeBatch(syncEngine: syncEngine.private) #expect(batch == nil) - let remindersLists = try await database.userRead { db in + let remindersLists = try await userDatabase.userRead { db in try MigratedRemindersList.order(by: \.id).fetchAll(db) } - let reminders = try await database.userRead { db in + let reminders = try await userDatabase.userRead { db in try MigratedReminder.order(by: \.id).fetchAll(db) } expectNoDifference( diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index b51d77d2..3172fe9e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -10,7 +10,7 @@ extension BaseCloudKitTests { final class TriggerTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func triggers() async throws { - let triggersAfterSetUp = try await database.userWrite { db in + let triggersAfterSetUp = try await userDatabase.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { @@ -419,7 +419,7 @@ extension BaseCloudKitTests { } try await syncEngine.tearDownSyncEngine() - let triggersAfterTearDown = try await database.userWrite { db in + let triggersAfterTearDown = try await userDatabase.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } assertInlineSnapshot(of: triggersAfterTearDown, as: .customDump) { @@ -429,7 +429,7 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - let triggersAfterReSetUp = try await database.userWrite { db in + let triggersAfterReSetUp = try await userDatabase.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } expectNoDifference(triggersAfterReSetUp, triggersAfterSetUp) diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index d29b1808..ad87ba23 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -11,7 +11,7 @@ import Testing .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)) ) class BaseCloudKitTests: @unchecked Sendable { - let database: UserDatabase + let userDatabase: UserDatabase private let _syncEngine: any Sendable @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -28,7 +28,7 @@ class BaseCloudKitTests: @unchecked Sendable { let database = UserDatabase( database: try SharingGRDBTests.database(containerIdentifier: testContainerIdentifier) ) - self.database = database + self.userDatabase = database try await database.userWrite { db in try db.seed { seeds } } @@ -41,7 +41,7 @@ class BaseCloudKitTests: @unchecked Sendable { ), privateDatabase: privateDatabase, sharedDatabase: sharedDatabase, - database: self.database, + userDatabase: self.userDatabase, metadatabaseURL: URL.metadatabase(containerIdentifier: testContainerIdentifier), tables: [ Reminder.self, @@ -86,7 +86,7 @@ extension SyncEngine { container: any CloudContainer, privateDatabase: MockCloudDatabase, sharedDatabase: MockCloudDatabase, - database: UserDatabase, + userDatabase: UserDatabase, metadatabaseURL: URL, tables: [any PrimaryKeyedTable.Type], privateTables: [any PrimaryKeyedTable.Type] = [] @@ -109,12 +109,12 @@ extension SyncEngine { ) ) }, - database: database, + userDatabase: userDatabase, logger: Logger(.disabled), metadatabaseURL: metadatabaseURL, tables: tables, privateTables: privateTables ) - try await setUpSyncEngine(database: database, metadatabase: metadatabase)?.value + try await setUpSyncEngine(userDatabase: userDatabase, metadatabase: metadatabase)?.value } } From 240d84faa7ffe4119654fa4b365a618a57597f62 Mon Sep 17 00:00:00 2001 From: Sean Woodward Date: Tue, 1 Jul 2025 11:24:39 -0500 Subject: [PATCH 267/581] Update recordName and parentRecordName comment to reflect actual implementation --- Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index ba6f16a5..1150e287 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -21,7 +21,7 @@ public struct SyncMetadata: Hashable, Sendable { /// the format "tableName:primaryKey", for example: /// /// ```swift - /// "reminders:8c4d1e4e-49b2-4f60-b6df-3c23881b87c6" + /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" /// ``` // @Column(primaryKey: true) public var recordName: RecordName @@ -32,7 +32,7 @@ public struct SyncMetadata: Hashable, Sendable { /// "tableName:primaryKey", for example: /// /// ```swift - /// "remindersLists:d35e1f81-46e4-45d1-904b-2b7df1661e3e" + /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" /// ``` public var parentRecordName: RecordName? From 054b649f2a255c8953c7a52784e408118912ff03 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 12:24:58 -0400 Subject: [PATCH 268/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 323 +++++++++--------- .../SharingGRDBCore/CloudKit/Triggers.swift | 10 +- .../Documentation.docc/Articles/CloudKit.md | 26 ++ .../Internal/UserDatabase.swift | 8 +- .../CloudKitTests/CloudKitTests.swift | 6 +- .../CloudKitTests/TriggerTests.swift | 6 +- 6 files changed, 197 insertions(+), 182 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 8c9a270d..1df4558c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -10,8 +10,7 @@ zoneName: "co.pointfree.SQLiteData.defaultZone" ) - // TODO: Rename to isUpdatingFromServer / isHandlingServerUpdates - @TaskLocal static var isUpdatingWithServerRecord = false + @TaskLocal static var _isUpdatingRecord = false let userDatabase: UserDatabase let logger: Logger @@ -152,7 +151,7 @@ .execute(db) } db.add(function: .datetime) - db.add(function: .isUpdatingWithServerRecord) + db.add(function: .syncEngineIsUpdatingRecord) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) @@ -283,7 +282,7 @@ } db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) - db.remove(function: .isUpdatingWithServerRecord) + db.remove(function: .syncEngineIsUpdatingRecord) db.remove(function: .datetime) } try await userDatabase.write { db in @@ -370,6 +369,10 @@ ) ) } + + public static func isUpdatingRecord() -> SQLQueryExpression { + SQLQueryExpression("\(raw: DatabaseFunction.syncEngineIsUpdatingRecord.name)()") + } } extension PrimaryKeyedTable { @@ -683,23 +686,21 @@ syncEngine: any SyncEngineProtocol ) { // TODO: How to handle this? - Self.$isUpdatingWithServerRecord.withValue(true) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in - for deletion in deletions { - // if let table = tablesByName[deletion.zoneID.zoneName] { - // func open(_: T.Type) { - // withErrorReporting(.sqliteDataCloudKitFailure) { - // try T.delete().execute(db) - // } - // } - // open(table) - } + withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.write { db in + for deletion in deletions { + // if let table = tablesByName[deletion.zoneID.zoneName] { + // func open(_: T.Type) { + // withErrorReporting(.sqliteDataCloudKitFailure) { + // try T.delete().execute(db) + // } + // } + // open(table) } - - // TODO: Deal with modifications? - _ = modifications } + + // TODO: Deal with modifications? + _ = modifications } } @@ -708,57 +709,55 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { - await Self.$isUpdatingWithServerRecord.withValue(true) { - for record in modifications { - if let share = record as? CKShare { - await withErrorReporting { - try await cacheShare(share) - } - } else { - upsertFromServerRecord(record) - await refreshLastKnownServerRecord(record) + for record in modifications { + if let share = record as? CKShare { + await withErrorReporting { + try await cacheShare(share) } - if let shareReference = record.share, - // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in - let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), - let share = shareRecord as? CKShare - { - await withErrorReporting { - try await cacheShare(share) - } + } else { + upsertFromServerRecord(record) + await refreshLastKnownServerRecord(record) + } + if let shareReference = record.share, + // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in + let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), + let share = shareRecord as? CKShare + { + await withErrorReporting { + try await cacheShare(share) } } + } - for (recordID, recordType) in deletions { - if let table = tablesByName[recordType] { - guard let recordName = SyncMetadata.RecordName(recordID: recordID) - else { - continue - } - func open>(_: T.Type) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in - try T.find(recordName.id) - .delete() - .execute(db) - } + for (recordID, recordType) in deletions { + if let table = tablesByName[recordType] { + guard let recordName = SyncMetadata.RecordName(recordID: recordID) + else { + continue + } + func open>(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.write { db in + try T.find(recordName.id) + .delete() + .execute(db) } } - open(table) - } else if recordType == CKRecord.SystemType.share { - withErrorReporting { - try deleteShare(recordID: recordID, recordType: recordType) - } - } else { - // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? - reportIssue( - .sqliteDataCloudKitFailure.appending( + } + open(table) + } else if recordType == CKRecord.SystemType.share { + withErrorReporting { + try deleteShare(recordID: recordID, recordType: recordType) + } + } else { + // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? + reportIssue( + .sqliteDataCloudKitFailure.appending( """ : No table to delete from: "\(recordType)" """ - ) ) - } + ) } } } @@ -789,13 +788,11 @@ func clearServerRecord() { withErrorReporting { - try Self.$isUpdatingWithServerRecord.withValue(true) { - try userDatabase.write { db in - try SyncMetadata - .find(recordName) - .update { $0.lastKnownServerRecord = nil } - .execute(db) - } + try userDatabase.write { db in + try SyncMetadata + .find(recordName) + .update { $0.lastKnownServerRecord = nil } + .execute(db) } } } @@ -883,128 +880,124 @@ } private func upsertFromServerRecord(_ serverRecord: CKRecord) { - Self.$isUpdatingWithServerRecord.withValue(true) { - withErrorReporting(.sqliteDataCloudKitFailure) { - guard let table = tablesByName[serverRecord.recordType] - else { - // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? - reportIssue( - .sqliteDataCloudKitFailure.appending( + withErrorReporting(.sqliteDataCloudKitFailure) { + guard let table = tablesByName[serverRecord.recordType] + else { + // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? + reportIssue( + .sqliteDataCloudKitFailure.appending( """ : No table to merge from: "\(serverRecord.recordType)" """ - ) ) - return - } - guard let recordName = SyncMetadata.RecordName(recordID: serverRecord.recordID) - else { - return - } - let userModificationDate = - try metadatabase.read { db in - try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( - db - ) - } - ?? nil - guard - let userModificationDate, - userModificationDate > serverRecord.userModificationDate ?? .distantPast - else { - // TODO: This should be fetched early and held onto (like 'ForeignKey') - let columnNames = try userDatabase.read { db in - try SQLQueryExpression( + ) + return + } + guard let recordName = SyncMetadata.RecordName(recordID: serverRecord.recordID) + else { + return + } + let userModificationDate = + try metadatabase.read { db in + try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( + db + ) + } + ?? nil + guard + let userModificationDate, + userModificationDate > serverRecord.userModificationDate ?? .distantPast + else { + // TODO: This should be fetched early and held onto (like 'ForeignKey') + let columnNames = try userDatabase.read { db in + try SQLQueryExpression( """ SELECT "name" FROM pragma_table_info(\(bind: table.tableName)) """, as: String.self - ) - .fetchAll(db) - } - var query: QueryFragment = "INSERT INTO \(table) (" - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) - query.append(") VALUES (") - let encryptedValues = serverRecord.encryptedValues - query.append( - columnNames - .map { columnName in - if let asset = serverRecord[columnName] as? CKAsset { - return (try? asset.fileURL.map { try Data(contentsOf: $0) })? - .queryFragment ?? "NULL" - } else { - return encryptedValues[columnName]?.queryFragment ?? "NULL" - } - } - .joined(separator: ", ") ) - func open(_: T.Type) -> String { - T.columns.primaryKey.name - } - let primaryKeyName = open(table) - query.append(") ON CONFLICT(\(quote: primaryKeyName)) DO UPDATE SET ") + .fetchAll(db) + } + var query: QueryFragment = "INSERT INTO \(table) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + let encryptedValues = serverRecord.encryptedValues + query.append( + columnNames + .map { columnName in + if let asset = serverRecord[columnName] as? CKAsset { + return (try? asset.fileURL.map { try Data(contentsOf: $0) })? + .queryFragment ?? "NULL" + } else { + return encryptedValues[columnName]?.queryFragment ?? "NULL" + } + } + .joined(separator: ", ") + ) + func open(_: T.Type) -> String { + T.columns.primaryKey.name + } + let primaryKeyName = open(table) + query.append(") ON CONFLICT(\(quote: primaryKeyName)) DO UPDATE SET ") - query.append( - columnNames - .filter { columnName in columnName != primaryKeyName } - .map { + query.append( + columnNames + .filter { columnName in columnName != primaryKeyName } + .map { """ \(quote: $0) = "excluded".\(quote: $0) """ - } - .joined(separator: ",") - ) - // TODO: Append more ON CONFLICT clauses for each unique constraint? - // TODO: Use WHERE to scope the update? - guard let metadata = SyncMetadata(record: serverRecord) - else { - reportIssue("???") - return - } - try userDatabase.write { db in - try SQLQueryExpression(query).execute(db) - try SyncMetadata - .insert { - metadata - } onConflictDoUpdate: { - $0.lastKnownServerRecord = serverRecord - $0.userModificationDate = serverRecord.userModificationDate - } - .execute(db) - } + } + .joined(separator: ",") + ) + // TODO: Append more ON CONFLICT clauses for each unique constraint? + // TODO: Use WHERE to scope the update? + guard let metadata = SyncMetadata(record: serverRecord) + else { + reportIssue("???") return } + try userDatabase.write { db in + try SQLQueryExpression(query).execute(db) + try SyncMetadata + .insert { + metadata + } onConflictDoUpdate: { + $0.lastKnownServerRecord = serverRecord + $0.userModificationDate = serverRecord.userModificationDate + } + .execute(db) + } + return } } } private func refreshLastKnownServerRecord(_ record: CKRecord) async { - await Self.$isUpdatingWithServerRecord.withValue(true) { - guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) - else { - return - } - let metadata = await metadataFor(recordName: recordName) + guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) + else { + return + } + let metadata = await metadataFor(recordName: recordName) - func updateLastKnownServerRecord() { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in - try SyncMetadata - .find(recordName) - .update { $0.lastKnownServerRecord = record } - .execute(db) - } + func updateLastKnownServerRecord() { + withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.write { db in + try SyncMetadata + .find(recordName) + .update { $0.lastKnownServerRecord = record } + .execute(db) } } + } - if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { - if let recordDate = record.modificationDate, lastKnownDate < recordDate { - updateLastKnownServerRecord() - } - } else { + if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { + if let recordDate = record.modificationDate, lastKnownDate < recordDate { updateLastKnownServerRecord() } + } else { + updateLastKnownServerRecord() } } @@ -1047,10 +1040,10 @@ } } - fileprivate static var isUpdatingWithServerRecord: Self { - Self(.sqliteDataCloudKitSchemaName + "_" + "isUpdatingWithServerRecord", argumentCount: 0) { + fileprivate static var syncEngineIsUpdatingRecord: Self { + Self(.sqliteDataCloudKitSchemaName + "_" + "syncEngineIsUpdatingRecord", argumentCount: 0) { _ in - SyncEngine.isUpdatingWithServerRecord + SyncEngine._isUpdatingRecord } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 784e0aa7..56888b34 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -87,7 +87,7 @@ extension SyncMetadata { after: .insert { new in Values(.didUpdate(new)) } when: { _ in - !isUpdatingWithServerRecord() + !SyncEngine.isUpdatingRecord() } ) @@ -97,7 +97,7 @@ extension SyncMetadata { after: .update { _, new in Values(.didUpdate(new)) } when: { _, _ in - !isUpdatingWithServerRecord() + !SyncEngine.isUpdatingRecord() } ) @@ -107,7 +107,7 @@ extension SyncMetadata { after: .delete { old in Values(.didDelete(old)) } when: { _ in - !isUpdatingWithServerRecord() + !SyncEngine.isUpdatingRecord() } ) } @@ -160,10 +160,6 @@ extension QueryExpression where Self == SQLQueryExpression<()> { } } -private func isUpdatingWithServerRecord() -> SQLQueryExpression { - SQLQueryExpression("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") -} - extension QueryExpression { fileprivate static func datetime>() -> Self where Self == SQLQueryExpression { diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index e86881db..2b2a1b48 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -406,6 +406,32 @@ It is possible to ## Separating schema migrations from data migrations +## Tips and tricks + +### Updating triggers to be compatible with synchronization + +```swift +#sql(""" + CREATE TEMPORARY TRIGGER "…" + AFTER DELETE ON "…"" + FOR EACH ROW WHEN NOT \(SyncEngine.isUpdatingRecord()) + BEGIN + … + END + """) +``` + +```swift +createTemporaryTrigger( + "…", + after: .insert { new in + … + } when: { _ in + !SyncEngine.isUpdatingRecord() + } +) +``` + ## Topics ### Go deeper diff --git a/Sources/SharingGRDBCore/Internal/UserDatabase.swift b/Sources/SharingGRDBCore/Internal/UserDatabase.swift index 2b0474a9..71033d9f 100644 --- a/Sources/SharingGRDBCore/Internal/UserDatabase.swift +++ b/Sources/SharingGRDBCore/Internal/UserDatabase.swift @@ -17,7 +17,7 @@ package struct UserDatabase { ) async throws -> T { try await withEscapedDependencies { dependencies in try await database.write { db in - try SyncEngine.$isUpdatingWithServerRecord.withValue(true) { + try SyncEngine.$_isUpdatingRecord.withValue(true) { try dependencies.yield { try updates(db) } @@ -31,7 +31,7 @@ package struct UserDatabase { ) async throws -> T { try await withEscapedDependencies { dependencies in try await database.read { db in - try SyncEngine.$isUpdatingWithServerRecord.withValue(true) { + try SyncEngine.$_isUpdatingRecord.withValue(true) { try dependencies.yield { try updates(db) } @@ -46,7 +46,7 @@ package struct UserDatabase { ) throws -> T { try withEscapedDependencies { dependencies in try database.write { db in - try SyncEngine.$isUpdatingWithServerRecord.withValue(true) { + try SyncEngine.$_isUpdatingRecord.withValue(true) { try dependencies.yield { try updates(db) } @@ -61,7 +61,7 @@ package struct UserDatabase { ) throws -> T { try withEscapedDependencies { dependencies in try database.read { db in - try SyncEngine.$isUpdatingWithServerRecord.withValue(true) { + try SyncEngine.$_isUpdatingRecord.withValue(true) { try dependencies.yield { try updates(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 22364e7e..0f66a71c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -212,9 +212,9 @@ extension BaseCloudKitTests { ) { """ [ - [0]: "sqlitedata_icloud_datetime", - [1]: "sqlitedata_icloud_didupdate", - [2]: "sqlitedata_icloud_isupdatingwithserverrecord", + [0]: "sqlitedata_icloud_syncengineisupdatingrecord", + [1]: "sqlitedata_icloud_datetime", + [2]: "sqlitedata_icloud_didupdate", [3]: "sqlitedata_icloud_diddelete" ] """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 3172fe9e..c1a9f6e6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -19,7 +19,7 @@ extension BaseCloudKitTests { [0]: """ CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" AFTER DELETE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsUpdatingRecord()) BEGIN SELECT sqlitedata_icloud_didDelete("old"."recordName", coalesce("old"."lastKnownServerRecord", ( SELECT "sqlitedata_icloud_metadata"."lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" @@ -30,7 +30,7 @@ extension BaseCloudKitTests { [1]: """ CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsUpdatingRecord()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( SELECT "sqlitedata_icloud_metadata"."lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" @@ -41,7 +41,7 @@ extension BaseCloudKitTests { [2]: """ CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_isUpdatingWithServerRecord()) BEGIN + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsUpdatingRecord()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( SELECT "sqlitedata_icloud_metadata"."lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" From 7b2bedb73fcc8fff97dedadd7af618c0efb1be52 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 12:25:09 -0400 Subject: [PATCH 269/581] wip --- Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 2b2a1b48..4856ba3e 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -410,6 +410,8 @@ It is possible to ### Updating triggers to be compatible with synchronization + + ```swift #sql(""" CREATE TEMPORARY TRIGGER "…" From b0059a9a7ac769c9f18f5e96d61d8e89807da5ae Mon Sep 17 00:00:00 2001 From: Sean Woodward Date: Tue, 1 Jul 2025 11:25:13 -0500 Subject: [PATCH 270/581] readability --- Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index e86881db..0e51bc77 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -114,7 +114,7 @@ This will allow you to query the ``SyncMetadata`` table, which gives you access stored for each of your records, as well as the `CKShare` for any shared records. See the ``GRDB/Database/attachMetadatabase(containerIdentifier:)`` for more information, as well -as below . +as below. ## Designing your schema with synchronization in mind From ec78f48d677a501741aa3de4247c09d7ca661363 Mon Sep 17 00:00:00 2001 From: Sean Woodward Date: Tue, 1 Jul 2025 11:26:12 -0500 Subject: [PATCH 271/581] Clarity around implementation of foreign key constraints --- .../SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 0e51bc77..a5db9eac 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -280,9 +280,9 @@ CREATE TABLE "reminders" ( ) ``` -…and while the constraint will not be enforced, the "ON DELETE CASCADE" will still be implemented, -i.e. when a reminders list is deleted, all of its associated reminders will also be deleted, -and everything will be synchronized to all devices. +…and while the constraint will not be enforced, the "ON DELETE CASCADE" will still be implemented by +``ForeignKey`` triggers created in ``SyncEngine`` setup, i.e. when a reminders list is deleted, all +of its associated reminders will also be deleted, and everything will be synchronized to all devices. ## Record conflicts From e2fead0fe83ed82c10e863d1292f8bd93deebf32 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 13:16:55 -0400 Subject: [PATCH 272/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 26 +++++---- .../Internal/UserDatabase.swift | 58 ++----------------- .../Internal/UserDatabaseHelpers.swift | 46 +++++++++++++++ 3 files changed, 64 insertions(+), 66 deletions(-) create mode 100644 Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 1df4558c..747aa5a4 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -10,7 +10,7 @@ zoneName: "co.pointfree.SQLiteData.defaultZone" ) - @TaskLocal static var _isUpdatingRecord = false + @TaskLocal package static var _isUpdatingRecord = false let userDatabase: UserDatabase let logger: Logger @@ -205,18 +205,20 @@ else { return nil } withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.userWrite { db in - for (recordType, isNewTable) in recordTypesToFetch { - try RecordType - .upsert { RecordType.Draft(recordType) } - .execute(db) - if isNewTable, let table = tablesByName[recordType.tableName] { - func open>(_: T.Type) throws { - try T - .update { $0.primaryKey = $0.primaryKey } - .execute(db) + try userDatabase.write { db in + try Self.$_isUpdatingRecord.withValue(false) { + for (recordType, isNewTable) in recordTypesToFetch { + try RecordType + .upsert { RecordType.Draft(recordType) } + .execute(db) + if isNewTable, let table = tablesByName[recordType.tableName] { + func open>(_: T.Type) throws { + try T + .update { $0.primaryKey = $0.primaryKey } + .execute(db) + } + try open(table) } - try open(table) } } } diff --git a/Sources/SharingGRDBCore/Internal/UserDatabase.swift b/Sources/SharingGRDBCore/Internal/UserDatabase.swift index 71033d9f..ebf00f7a 100644 --- a/Sources/SharingGRDBCore/Internal/UserDatabase.swift +++ b/Sources/SharingGRDBCore/Internal/UserDatabase.swift @@ -12,7 +12,7 @@ package struct UserDatabase { database.configuration } - func write( + package func write( _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await withEscapedDependencies { dependencies in @@ -26,7 +26,7 @@ package struct UserDatabase { } } - func read( + package func read( _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await withEscapedDependencies { dependencies in @@ -41,7 +41,7 @@ package struct UserDatabase { } @_disfavoredOverload - func write( + package func write( _ updates: (Database) throws -> T ) throws -> T { try withEscapedDependencies { dependencies in @@ -56,7 +56,7 @@ package struct UserDatabase { } @_disfavoredOverload - func read( + package func read( _ updates: (Database) throws -> T ) throws -> T { try withEscapedDependencies { dependencies in @@ -69,54 +69,4 @@ package struct UserDatabase { } } } - - package func userWrite( - _ updates: @escaping @Sendable (Database) throws -> T - ) async throws -> T { - try await withEscapedDependencies { dependencies in - try await database.write { db in - try dependencies.yield { - try updates(db) - } - } - } - } - - package func userRead( - _ updates: @escaping @Sendable (Database) throws -> T - ) async throws -> T { - try await withEscapedDependencies { dependencies in - try await database.read { db in - try dependencies.yield { - try updates(db) - } - } - } - } - - @_disfavoredOverload - package func userWrite( - _ updates: (Database) throws -> T - ) throws -> T { - try withEscapedDependencies { dependencies in - try database.write { db in - try dependencies.yield { - try updates(db) - } - } - } - } - - @_disfavoredOverload - package func userRead( - _ updates: (Database) throws -> T - ) throws -> T { - try withEscapedDependencies { dependencies in - try database.read { db in - try dependencies.yield { - try updates(db) - } - } - } - } } diff --git a/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift b/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift new file mode 100644 index 00000000..00540bfb --- /dev/null +++ b/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift @@ -0,0 +1,46 @@ +import GRDB +import SharingGRDBCore + +extension UserDatabase { + func userWrite( + _ updates: @escaping @Sendable (Database) throws -> T + ) async throws -> T { + try await write { db in + try SyncEngine.$_isUpdatingRecord.withValue(false) { + try updates(db) + } + } + } + + func userRead( + _ updates: @escaping @Sendable (Database) throws -> T + ) async throws -> T { + try await read { db in + try SyncEngine.$_isUpdatingRecord.withValue(false) { + try updates(db) + } + } + } + + @_disfavoredOverload + func userWrite( + _ updates: (Database) throws -> T + ) throws -> T { + try write { db in + try SyncEngine.$_isUpdatingRecord.withValue(false) { + try updates(db) + } + } + } + + @_disfavoredOverload + func userRead( + _ updates: (Database) throws -> T + ) throws -> T { + try write { db in + try SyncEngine.$_isUpdatingRecord.withValue(false) { + try updates(db) + } + } + } +} From d06f762c342747fc677a549b0743a1edc109f441 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 13:18:00 -0400 Subject: [PATCH 273/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 747aa5a4..d87890da 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -10,8 +10,6 @@ zoneName: "co.pointfree.SQLiteData.defaultZone" ) - @TaskLocal package static var _isUpdatingRecord = false - let userDatabase: UserDatabase let logger: Logger package let metadatabase: any DatabaseReader @@ -123,6 +121,8 @@ ) } + @TaskLocal package static var _isUpdatingRecord = false + package func setUpSyncEngine() async throws { try await setUpSyncEngine(userDatabase: userDatabase, metadatabase: metadatabase)?.value } From aba2ff6ff8e371c2881280d6eb193e3fd3d67fc3 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 1 Jul 2025 10:52:06 -0700 Subject: [PATCH 274/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 47 ++++++++++++------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 1 - 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index d71fa6e6..97380091 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -99,21 +99,21 @@ extension CKDatabase.Scope { } } -extension CKRecordKeyValueSetting { +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +extension CKRecord { package func setValue( _ newValue: some CKRecordValueProtocol & Equatable, forKey key: CKRecord.FieldKey, at userModificationDate: Date? ) { - if self[key] != newValue { - self[key] = newValue - self[ + if encryptedValues[key] != newValue { + encryptedValues[key] = newValue + encryptedValues[ "\(String.sqliteDataCloudKitSchemaName)_userModificationDate_\(key)" ] = userModificationDate } } - @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) package func setValue( _ newValue: [UInt8], forKey key: CKRecord.FieldKey, @@ -127,6 +127,7 @@ extension CKRecordKeyValueSetting { try Data(newValue).write(to: blobURL) } self[key] = asset + // TODO: This should be 'encryptedValues[' self[ "\(String.sqliteDataCloudKitSchemaName)_userModificationDate_\(key)" ] = userModificationDate @@ -137,17 +138,14 @@ extension CKRecordKeyValueSetting { forKey key: CKRecord.FieldKey, at userModificationDate: Date? ) { - if self[key] != nil { - self[key] = nil - self[ + if encryptedValues[key] != nil { + encryptedValues[key] = nil + encryptedValues[ "\(String.sqliteDataCloudKitSchemaName)_userModificationDate_\(key)" ] = userModificationDate } } -} -@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -extension CKRecord { package func update(with row: T, userModificationDate: Date?) { self.userModificationDate = userModificationDate for column in T.TableColumns.allColumns { @@ -156,19 +154,19 @@ extension CKRecord { let value = Value(queryOutput: row[keyPath: column.keyPath]) switch value.queryBinding { case .blob(let value): - encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) + setValue(value, forKey: column.name, at: userModificationDate) case .double(let value): - encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) + setValue(value, forKey: column.name, at: userModificationDate) case .date(let value): - encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) + setValue(value, forKey: column.name, at: userModificationDate) case .int(let value): - encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) + setValue(value, forKey: column.name, at: userModificationDate) case .null: - encryptedValues.removeValue(forKey: column.name, at: userModificationDate) + removeValue(forKey: column.name, at: userModificationDate) case .text(let value): - encryptedValues.setValue(value, forKey: column.name, at: userModificationDate) + setValue(value, forKey: column.name, at: userModificationDate) case .uuid(let value): - encryptedValues.setValue( + setValue( value.uuidString.lowercased(), forKey: column.name, at: userModificationDate @@ -186,6 +184,19 @@ extension CKRecord { set { encryptedValues[Self.userModificationDateKey] = newValue } } + package var userModificationDates: [String: Date] { + var userModificationDates: [String: Date] = [:] + for key in encryptedValues.allKeys() { + guard + key.hasPrefix("\(CKRecord.userModificationDateKey)_"), + let date = encryptedValues[key] as? Date + else { continue } + let key = String(key.dropFirst(CKRecord.userModificationDateKey.count + 1)) + userModificationDates[key] = date + } + return userModificationDates + } + private static let userModificationDateKey = "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6c5e1da6..59c3f759 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1296,5 +1296,4 @@ result[table.type.tableName] = result.count } } - #endif From 6b07314c01dd124781f7df0fcb84f5eddb6b3f10 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 1 Jul 2025 11:10:41 -0700 Subject: [PATCH 275/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 96 ++++++++----------- .../SyncMetadata+MacroExpansion.swift | 8 +- 2 files changed, 42 insertions(+), 62 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 97380091..692a4afb 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -4,78 +4,58 @@ import CryptoKit import CustomDump import StructuredQueriesCore -extension CKRecord { - public struct DataRepresentation: QueryBindable, QueryRepresentable { - public let queryOutput: CKRecord +extension _CKRecord where Self == CKRecord { + public typealias DataRepresentation = _DataRepresentation +} - public var queryBinding: QueryBinding { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - queryOutput.encodeSystemFields(with: archiver) - if isTesting { - archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") - } - return archiver.encodedData.queryBinding - } +extension _CKRecord where Self == CKShare { + public typealias DataRepresentation = _DataRepresentation +} - public init(queryOutput: CKRecord) { - self.queryOutput = queryOutput - } +extension Optional where Wrapped: CKRecord { + public typealias DataRepresentation = _DataRepresentation? +} - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - guard let data = try Data?(decoder: &decoder) else { - throw QueryDecodingError.missingRequiredColumn - } - let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true - guard let queryOutput = CKRecord(coder: coder) else { - throw DecodingError() - } - if isTesting { - queryOutput._recordChangeTag = coder - .decodeObject(of: NSString.self, forKey: "_recordChangeTag") - as? String - } - self.init(queryOutput: queryOutput) - } +public struct _DataRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: Record - private struct DecodingError: Error {} + public var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encodeSystemFields(with: archiver) + if isTesting { + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + } + return archiver.encodedData.queryBinding } -} -extension CKShare { - // TODO: Confirm that it's not possible to name this 'DataRepresentation' - public struct ShareDataRepresentation: QueryBindable, QueryRepresentable { - public let queryOutput: CKShare + public init(queryOutput: Record) { + self.queryOutput = queryOutput + } - public var queryBinding: QueryBinding { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - queryOutput.encodeSystemFields(with: archiver) - return archiver.encodedData.queryBinding + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + guard let data = try Data?(decoder: &decoder) else { + throw QueryDecodingError.missingRequiredColumn } - - public init(queryOutput: CKShare) { - self.queryOutput = queryOutput + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = Record(coder: coder) else { + throw DecodingError() } - - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - guard let data = try Data?(decoder: &decoder) else { - throw QueryDecodingError.missingRequiredColumn - } - let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true - self.init(queryOutput: CKShare(coder: coder)) + if isTesting { + queryOutput._recordChangeTag = coder + .decodeObject(of: NSString.self, forKey: "_recordChangeTag") + as? String } - - private struct DecodingError: Error {} + self.init(queryOutput: queryOutput) } -} -extension CKRecord? { - public typealias DataRepresentation = CKRecord.DataRepresentation? + private struct DecodingError: Error {} } -extension CKShare? { - public typealias ShareDataRepresentation = CKShare.ShareDataRepresentation? +extension CKRecord: _CKRecord {} + +public protocol _CKRecord { + associatedtype DataRepresentation } extension CKDatabase.Scope { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 973045d3..1a1a9e6b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -9,7 +9,7 @@ extension SyncMetadata { public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) + public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public var primaryKey: StructuredQueriesCore.TableColumn { self.recordName @@ -33,7 +33,7 @@ extension SyncMetadata { public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) + public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] @@ -48,7 +48,7 @@ extension SyncMetadata { self.recordName = try decoder.decode(RecordName.self) self.parentRecordName = try decoder.decode(RecordName.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) - let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) + let share = try decoder.decode(CKShare?.DataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) guard let recordType else { throw QueryDecodingError.missingRequiredColumn @@ -98,7 +98,7 @@ extension SyncMetadata { let recordName = try decoder.decode(RecordName.self) self.parentRecordName = try decoder.decode(RecordName.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) - let share = try decoder.decode(CKShare?.ShareDataRepresentation.self) + let share = try decoder.decode(CKShare?.DataRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) guard let recordType else { throw QueryDecodingError.missingRequiredColumn From f35339933ad2f6d247dc6bb8233803967175ab4f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 1 Jul 2025 11:12:19 -0700 Subject: [PATCH 276/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 10 +++++----- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../CloudKit/SyncMetadata+MacroExpansion.swift | 16 ++++++++-------- Sources/SharingGRDBCore/CloudKit/Triggers.swift | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 692a4afb..7470c30f 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -5,18 +5,18 @@ import CustomDump import StructuredQueriesCore extension _CKRecord where Self == CKRecord { - public typealias DataRepresentation = _DataRepresentation + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } extension _CKRecord where Self == CKShare { - public typealias DataRepresentation = _DataRepresentation + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } extension Optional where Wrapped: CKRecord { - public typealias DataRepresentation = _DataRepresentation? + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? } -public struct _DataRepresentation: QueryBindable, QueryRepresentable { +public struct _SystemFieldsRepresentation: QueryBindable, QueryRepresentable { public let queryOutput: Record public var queryBinding: QueryBinding { @@ -55,7 +55,7 @@ public struct _DataRepresentation: QueryBindable, QueryReprese extension CKRecord: _CKRecord {} public protocol _CKRecord { - associatedtype DataRepresentation + associatedtype SystemFieldsRepresentation } extension CKDatabase.Scope { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 59c3f759..c33c86e4 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -244,7 +244,7 @@ .select { SQLQueryExpression( "\($0.lastKnownServerRecord)", - as: CKRecord.DataRepresentation.self + as: CKRecord.SystemFieldsRepresentation.self ) } .fetchAll(db) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 1a1a9e6b..a6a2bab2 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -8,8 +8,8 @@ extension SyncMetadata { public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public var primaryKey: StructuredQueriesCore.TableColumn { self.recordName @@ -32,8 +32,8 @@ extension SyncMetadata { public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] @@ -47,8 +47,8 @@ extension SyncMetadata { let recordType = try decoder.decode(String.self) self.recordName = try decoder.decode(RecordName.self) self.parentRecordName = try decoder.decode(RecordName.self) - let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) - let share = try decoder.decode(CKShare?.DataRepresentation.self) + let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) + let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) guard let recordType else { throw QueryDecodingError.missingRequiredColumn @@ -97,8 +97,8 @@ extension SyncMetadata { let recordType = try decoder.decode(String.self) let recordName = try decoder.decode(RecordName.self) self.parentRecordName = try decoder.decode(RecordName.self) - let lastKnownServerRecord = try decoder.decode(CKRecord?.DataRepresentation.self) - let share = try decoder.decode(CKShare?.DataRepresentation.self) + let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) + let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) self.userModificationDate = try decoder.decode(Date.self) guard let recordType else { throw QueryDecodingError.missingRequiredColumn diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 784e0aa7..9f575a31 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -144,7 +144,7 @@ extension QueryExpression where Self == SQLQueryExpression<()> { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private static func didUpdate( recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression + lastKnownServerRecord: some QueryExpression ) -> Self { Self("\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(recordName), \(lastKnownServerRecord))") } @@ -152,7 +152,7 @@ extension QueryExpression where Self == SQLQueryExpression<()> { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private static func didDelete( recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression + lastKnownServerRecord: some QueryExpression ) -> Self { From 330dadf284eb57e7ef3a2a9fad54b97e575fe0bd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 14:17:58 -0400 Subject: [PATCH 277/581] Don't touch user records during initial upload. --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 137 ++++++++++++------ .../CloudKitTests/NewTableSyncTests.swift | 10 +- .../Internal/BaseCloudKitTests.swift | 2 +- 3 files changed, 99 insertions(+), 50 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d87890da..6e3587a5 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -204,43 +204,94 @@ guard !recordTypesToFetch.isEmpty else { return nil } + try cacheUserTables(recordTypes: recordTypesToFetch.map(\.0)) + try uploadRecordsToCloudKit( + recordTypes: recordTypesToFetch.compactMap { recordType, isNewTable in + isNewTable ? recordType : nil + } + ) + return Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await fetchChangesFromSchemaChange( + recordTypes: recordTypesToFetch.compactMap { recordType, isNewTable in + !isNewTable ? recordType : nil + } + ) + } + } + } + + private func cacheUserTables(recordTypes: [RecordType]) throws { + withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.write { db in + try RecordType + .upsert { recordTypes.map { RecordType.Draft($0) } } + .execute(db) + } + } + } + + private func uploadRecordsToCloudKit(recordTypes: [RecordType]) throws { withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in try Self.$_isUpdatingRecord.withValue(false) { - for (recordType, isNewTable) in recordTypesToFetch { - try RecordType - .upsert { RecordType.Draft(recordType) } - .execute(db) - if isNewTable, let table = tablesByName[recordType.tableName] { - func open>(_: T.Type) throws { - try T - .update { $0.primaryKey = $0.primaryKey } - .execute(db) + for recordType in recordTypes { + guard let table = tablesByName[recordType.tableName] + else { continue } + + let parentForeignKey = + foreignKeysByTableName[recordType.tableName]?.count == 1 + ? foreignKeysByTableName[recordType.tableName]?.first + : nil + + func open>(_: T.Type) throws { + try SyncMetadata.insert { columns in + ( + columns.recordType, + columns.recordName, + columns.parentRecordName + ) + } select: { + T.select { columns in + ( + SQLQueryExpression("\(quote: T.tableName, delimiter: .text)"), + SQLQueryExpression( + """ + \(columns.primaryKey) || ':' || \(quote: T.tableName, delimiter: .text) + """, + as: SyncMetadata.RecordName.self + ), + parentForeignKey.map { parentForeignKey in + SQLQueryExpression( + """ + \(quote: T.tableName, delimiter: .identifier)\ + .\(quote: parentForeignKey.from, delimiter: .identifier) \ + || ':' || \(quote: parentForeignKey.table, delimiter: .text) + """, + as: SyncMetadata.RecordName?.self + ) + } + ?? SQLQueryExpression("NULL") + ) + } } - try open(table) + .execute(db) } + try open(table) } } } } - - return Task { - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await fetchChangesFromSchemaChange( - recordTypesChanged: recordTypesToFetch.filter { !$0.isNewTable }.map(\.0) - ) - } - } } - private func fetchChangesFromSchemaChange(recordTypesChanged: [RecordType]) async throws { + private func fetchChangesFromSchemaChange(recordTypes: [RecordType]) async throws { // TODO: do batches for sake of CKDatabase // only docs we found was about modifies: https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation // recommends limiting to <400 records and <2mb data posted let lastKnownServerRecords = try await metadatabase.read { db in try SyncMetadata .where { - $0.recordType.in(recordTypesChanged.map(\.tableName)) + $0.recordType.in(recordTypes.map(\.tableName)) && $0.lastKnownServerRecord.isNot(nil) } .select { @@ -721,9 +772,9 @@ await refreshLastKnownServerRecord(record) } if let shareReference = record.share, - // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in - let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), - let share = shareRecord as? CKShare + // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in + let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), + let share = shareRecord as? CKShare { await withErrorReporting { try await cacheShare(share) @@ -755,9 +806,9 @@ // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? reportIssue( .sqliteDataCloudKitFailure.appending( - """ - : No table to delete from: "\(recordType)" - """ + """ + : No table to delete from: "\(recordType)" + """ ) ) } @@ -888,9 +939,9 @@ // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? reportIssue( .sqliteDataCloudKitFailure.appending( - """ - : No table to merge from: "\(serverRecord.recordType)" - """ + """ + : No table to merge from: "\(serverRecord.recordType)" + """ ) ) return @@ -900,12 +951,12 @@ return } let userModificationDate = - try metadatabase.read { db in - try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( - db - ) - } - ?? nil + try metadatabase.read { db in + try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( + db + ) + } + ?? nil guard let userModificationDate, userModificationDate > serverRecord.userModificationDate ?? .distantPast @@ -913,11 +964,11 @@ // TODO: This should be fetched early and held onto (like 'ForeignKey') let columnNames = try userDatabase.read { db in try SQLQueryExpression( - """ - SELECT "name" - FROM pragma_table_info(\(bind: table.tableName)) - """, - as: String.self + """ + SELECT "name" + FROM pragma_table_info(\(bind: table.tableName)) + """, + as: String.self ) .fetchAll(db) } @@ -947,9 +998,9 @@ columnNames .filter { columnName in columnName != primaryKeyName } .map { - """ - \(quote: $0) = "excluded".\(quote: $0) - """ + """ + \(quote: $0) = "excluded".\(quote: $0) + """ } .joined(separator: ",") ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 574cb8a1..f6cd5d66 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -35,8 +35,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Write blog post", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + title: "Write blog post" ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -44,8 +43,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -80,7 +78,7 @@ extension BaseCloudKitTests { share: nil ), share: nil, - userModificationDate: Date(2009-02-13T23:31:30.000Z) + userModificationDate: nil ), [1]: SyncMetadata( recordType: "remindersLists", @@ -96,7 +94,7 @@ extension BaseCloudKitTests { share: nil ), share: nil, - userModificationDate: Date(2009-02-13T23:31:30.000Z) + userModificationDate: nil ) ] """ diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index ad87ba23..77308b85 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,7 @@ import SnapshotTesting import Testing @Suite( - .snapshots(record: .failed), + .snapshots(record: .missing), .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)) ) class BaseCloudKitTests: @unchecked Sendable { From 95b140d68fef2144cb0316eb37a1829b2644124d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 14:25:43 -0400 Subject: [PATCH 278/581] clean up --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6e3587a5..edf8e046 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -264,8 +264,7 @@ parentForeignKey.map { parentForeignKey in SQLQueryExpression( """ - \(quote: T.tableName, delimiter: .identifier)\ - .\(quote: parentForeignKey.from, delimiter: .identifier) \ + \(T.self).\(quote: parentForeignKey.from, delimiter: .identifier) \ || ':' || \(quote: parentForeignKey.table, delimiter: .text) """, as: SyncMetadata.RecordName?.self From 33e758aadb1a52d554d86cc7be3149a9f09574a5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 15:27:08 -0400 Subject: [PATCH 279/581] Validate user triggers. --- Examples/Reminders/Schema.swift | 5 ++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 45 +++++++++++++- .../CloudKitTests/NewTableSyncTests.swift | 12 ++-- .../SyncEngineValidationTests.swift | 62 +++++++++++++++++++ .../Internal/BaseCloudKitTests.swift | 19 +++--- 5 files changed, 126 insertions(+), 17 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index b53c08f2..e2b6a5cc 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -213,12 +213,16 @@ func appDatabase() throws -> any DatabaseWriter { RemindersList .update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1} } .where { $0.id.eq(new.id) } + } when: { _ in + !SyncEngine.isUpdatingRecord() }) .execute(db) try Reminder.createTemporaryTrigger(after: .insert { new in Reminder .update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1} } .where { $0.id.eq(new.id) } + } when: { _ in + !SyncEngine.isUpdatingRecord() }) .execute(db) try RemindersList.createTemporaryTrigger( @@ -228,6 +232,7 @@ func appDatabase() throws -> any DatabaseWriter { } } when: { _ in RemindersList.count().eq(0) + && !SyncEngine.isUpdatingRecord() } ) .execute(db) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d87890da..c525cc64 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1174,8 +1174,40 @@ tables: [any PrimaryKeyedTable.Type], userDatabase: UserDatabase ) throws { - try userDatabase.read { db in + try userDatabase.write { db in for table in tables { + let triggers = try SQLQueryExpression( + """ + SELECT "name", "sql" + FROM "sqlite_master" + WHERE "type" = 'trigger' + """, + as: (String, String).self + ) + .fetchAll(db) + let temporaryTriggers = try SQLQueryExpression( + """ + SELECT "name", "sql" + FROM "sqlite_temp_master" + WHERE "type" = 'trigger' + """, + as: (String, String).self + ) + .fetchAll(db) + + let allTriggers = triggers + temporaryTriggers + let invalidTriggers = allTriggers.compactMap { name, sql in + let isValid = + sql + .lowercased() + .contains("not (\(DatabaseFunction.syncEngineIsUpdatingRecord.name)())".lowercased()) + return isValid ? nil : name + } + guard invalidTriggers.isEmpty + else { + throw InvalidUserTriggers(triggers: invalidTriggers) + } + // // TODO: write tests for this // let columnsWithUniqueConstraints = // try SQLQueryExpression( @@ -1207,6 +1239,17 @@ } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct InvalidUserTriggers: LocalizedError { + let triggers: [String] + public var localizedDescription: String { + """ + Triggers must include 'WHEN NOT \(DatabaseFunction.syncEngineIsUpdatingRecord.name)()' \ + clause: \(triggers.map { "'\($0)'" }.joined(separator: ", ")) + """ + } + } + public struct UniqueConstraintDisallowed: Error { let localizedDescription: String init(table: any PrimaryKeyedTable.Type, columns: [String]) { diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 574cb8a1..fc6f2efb 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -11,10 +11,14 @@ extension BaseCloudKitTests { final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { init() async throws { try await super.init( - seeds: [ - RemindersList(id: UUID(1), title: "Personal"), - Reminder(id: UUID(1), title: "Write blog post", remindersListID: UUID(1)) - ] + setUpUserDatabase: { userDatabase in + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), title: "Write blog post", remindersListID: UUID(1)) + } + } + } ) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift new file mode 100644 index 00000000..a2af870e --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -0,0 +1,62 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + struct SyncEngineValidationTests { + @Test + func userTriggerValidation() async throws { + let error = try await #require( + #expect(throws: InvalidUserTriggers.self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try RemindersList.createTemporaryTrigger( + after: .delete { _ in + RemindersList.insert { + RemindersList.Draft(title: "Personal") + } + } when: { _ in + RemindersList.count().eq(0) + } + ) + .execute(db) + } + let _ = try await SyncEngine.init( + container: MockCloudContainer( + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + metadatabaseURL: URL.temporaryDirectory.appending(path: UUID().uuidString), + tables: [ + RemindersList.self + ] + ) + } + ) + + #expect( + error.localizedDescription.hasPrefix( + """ + Triggers must include 'WHEN NOT sqlitedata_icloud_syncEngineIsUpdatingRecord()' clause: \ + 'after_delete_on_remindersLists@SharingGRDBTests + """ + ) + ) + } + } +} diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index ad87ba23..f4d84d70 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -22,16 +22,15 @@ class BaseCloudKitTests: @unchecked Sendable { typealias SendablePrimaryKeyedTable = PrimaryKeyedTable & Sendable @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init(seeds: [any SendablePrimaryKeyedTable] = []) async throws { + init( + setUpUserDatabase: @Sendable (UserDatabase) async throws -> Void = { _ in } + ) async throws { let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" - let database = UserDatabase( + self.userDatabase = UserDatabase( database: try SharingGRDBTests.database(containerIdentifier: testContainerIdentifier) ) - self.userDatabase = database - try await database.userWrite { db in - try db.seed { seeds } - } + try await setUpUserDatabase(userDatabase) let privateDatabase = MockCloudDatabase(databaseScope: .private) let sharedDatabase = MockCloudDatabase(databaseScope: .shared) _syncEngine = try await SyncEngine( @@ -39,8 +38,6 @@ class BaseCloudKitTests: @unchecked Sendable { privateCloudDatabase: privateDatabase, sharedCloudDatabase: sharedDatabase ), - privateDatabase: privateDatabase, - sharedDatabase: sharedDatabase, userDatabase: self.userDatabase, metadatabaseURL: URL.metadatabase(containerIdentifier: testContainerIdentifier), tables: [ @@ -84,8 +81,6 @@ extension SyncEngine { } convenience init( container: any CloudContainer, - privateDatabase: MockCloudDatabase, - sharedDatabase: MockCloudDatabase, userDatabase: UserDatabase, metadatabaseURL: URL, tables: [any PrimaryKeyedTable.Type], @@ -96,13 +91,13 @@ extension SyncEngine { defaultSyncEngines: { _, syncEngine in ( MockSyncEngine( - database: privateDatabase, + database: container.privateCloudDatabase as! MockCloudDatabase, delegate: syncEngine, scope: .private, state: MockSyncEngineState() ), MockSyncEngine( - database:sharedDatabase, + database: container.sharedCloudDatabase as! MockCloudDatabase, delegate: syncEngine, scope: .shared, state: MockSyncEngineState() From 8e38d907a15fb337abe4ca3fbeef2d844354012b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 15:31:42 -0400 Subject: [PATCH 280/581] clean up --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 39 ++++++++----------- .../SyncEngineValidationTests.swift | 26 ++++++++----- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c525cc64..296f5590 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1175,39 +1175,32 @@ userDatabase: UserDatabase ) throws { try userDatabase.write { db in - for table in tables { - let triggers = try SQLQueryExpression( + let triggers = try SQLQueryExpression( """ SELECT "name", "sql" FROM "sqlite_master" WHERE "type" = 'trigger' - """, - as: (String, String).self - ) - .fetchAll(db) - let temporaryTriggers = try SQLQueryExpression( - """ + UNION SELECT "name", "sql" FROM "sqlite_temp_master" WHERE "type" = 'trigger' """, as: (String, String).self - ) + ) .fetchAll(db) - - let allTriggers = triggers + temporaryTriggers - let invalidTriggers = allTriggers.compactMap { name, sql in - let isValid = - sql - .lowercased() - .contains("not (\(DatabaseFunction.syncEngineIsUpdatingRecord.name)())".lowercased()) - return isValid ? nil : name - } - guard invalidTriggers.isEmpty - else { - throw InvalidUserTriggers(triggers: invalidTriggers) - } - + let invalidTriggers = triggers.compactMap { name, sql in + let isValid = + sql + .lowercased() + .contains("not (\(DatabaseFunction.syncEngineIsUpdatingRecord.name)())".lowercased()) + return isValid ? nil : name + } + guard invalidTriggers.isEmpty + else { + throw InvalidUserTriggers(triggers: invalidTriggers) + } + + for table in tables { // // TODO: write tests for this // let columnsWithUniqueConstraints = // try SQLQueryExpression( diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index a2af870e..23e1ecd6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -24,15 +24,21 @@ extension BaseCloudKitTests { """ ) .execute(db) - try RemindersList.createTemporaryTrigger( - after: .delete { _ in - RemindersList.insert { - RemindersList.Draft(title: "Personal") - } - } when: { _ in - RemindersList.count().eq(0) - } - ) + try #sql(""" + CREATE TRIGGER "non_temporary_trigger" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + SELECT 1; + END + """) + .execute(db) + try #sql(""" + CREATE TEMPORARY TRIGGER "temporary_trigger" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + SELECT 1; + END + """) .execute(db) } let _ = try await SyncEngine.init( @@ -53,7 +59,7 @@ extension BaseCloudKitTests { error.localizedDescription.hasPrefix( """ Triggers must include 'WHEN NOT sqlitedata_icloud_syncEngineIsUpdatingRecord()' clause: \ - 'after_delete_on_remindersLists@SharingGRDBTests + 'non_temporary_trigger', 'temporary_trigger' """ ) ) From db3eedf70167f56a4a435d8416daa2f8de0b6f04 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 1 Jul 2025 15:35:21 -0400 Subject: [PATCH 281/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 296f5590..e2203e30 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1192,7 +1192,7 @@ let isValid = sql .lowercased() - .contains("not (\(DatabaseFunction.syncEngineIsUpdatingRecord.name)())".lowercased()) + .contains("\(DatabaseFunction.syncEngineIsUpdatingRecord.name)()".lowercased()) return isValid ? nil : name } guard invalidTriggers.isEmpty From ee0dd275cc72d33c1cc49b9bffadbc613d9c7df1 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 1 Jul 2025 15:21:35 -0700 Subject: [PATCH 282/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 174 +++++++++++++----- .../CloudKit/Metadatabase.swift | 10 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 158 +++++++++++----- .../SyncMetadata+MacroExpansion.swift | 20 +- .../CloudKit/SyncMetadata.swift | 10 +- .../SharingGRDBCore/CloudKit/Triggers.swift | 12 +- .../CloudKitTests/CloudKitTests.swift | 54 +++++- .../NextRecordZoneChangeBatchTests.swift | 6 +- .../Internal/CloudKitTestHelpers.swift | 10 +- 9 files changed, 332 insertions(+), 122 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 7470c30f..5866377c 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -5,14 +5,17 @@ import CustomDump import StructuredQueriesCore extension _CKRecord where Self == CKRecord { + public typealias AllFieldsRepresentation = _AllFieldsRepresentation public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } extension _CKRecord where Self == CKShare { + public typealias AllFieldsRepresentation = _AllFieldsRepresentation public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } extension Optional where Wrapped: CKRecord { + public typealias AllFieldsRepresentation = _AllFieldsRepresentation? public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? } @@ -52,9 +55,46 @@ public struct _SystemFieldsRepresentation: QueryBindable, Quer private struct DecodingError: Error {} } +public struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: Record + + public var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encode(with: archiver) + if isTesting { + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + } + return archiver.encodedData.queryBinding + } + + public init(queryOutput: Record) { + self.queryOutput = queryOutput + } + + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + guard let data = try Data?(decoder: &decoder) else { + throw QueryDecodingError.missingRequiredColumn + } + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = Record(coder: coder) else { + throw DecodingError() + } + if isTesting { + queryOutput._recordChangeTag = coder + .decodeObject(of: NSString.self, forKey: "_recordChangeTag") + as? String + } + self.init(queryOutput: queryOutput) + } + + private struct DecodingError: Error {} +} + extension CKRecord: _CKRecord {} public protocol _CKRecord { + associatedtype AllFieldsRepresentation associatedtype SystemFieldsRepresentation } @@ -79,26 +119,43 @@ extension CKDatabase.Scope { } } +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +extension CKRecordKeyValueSetting { + subscript(at key: String) -> Date { + get { + self["\(CKRecord.userModificationDateKey)_\(key)"] as? Date ?? .distantPast + } + set { + self["\(CKRecord.userModificationDateKey)_\(key)"] = max(self[at: key], newValue) + } + } +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { + @discardableResult package func setValue( _ newValue: some CKRecordValueProtocol & Equatable, forKey key: CKRecord.FieldKey, - at userModificationDate: Date? - ) { + at userModificationDate: Date + ) -> Bool { + guard encryptedValues[at: key] < userModificationDate else { return false } if encryptedValues[key] != newValue { encryptedValues[key] = newValue - encryptedValues[ - "\(String.sqliteDataCloudKitSchemaName)_userModificationDate_\(key)" - ] = userModificationDate + encryptedValues[at: key] = userModificationDate + return true } + return false } + @discardableResult package func setValue( _ newValue: [UInt8], forKey key: CKRecord.FieldKey, - at userModificationDate: Date? - ) { + at userModificationDate: Date + ) -> Bool { + // TODO: This should be 'encryptedValues[' + guard self[at: key] < userModificationDate else { return false } let hash = SHA256.hash(data: newValue).compactMap { String(format: "%02hhx", $0) }.joined() let blobURL = URL.temporaryDirectory.appendingPathComponent(hash) let asset = CKAsset(fileURL: blobURL) @@ -108,25 +165,51 @@ extension CKRecord { } self[key] = asset // TODO: This should be 'encryptedValues[' - self[ - "\(String.sqliteDataCloudKitSchemaName)_userModificationDate_\(key)" - ] = userModificationDate + self[at: key] = userModificationDate + return true + } + return false + } + + @discardableResult + package func setValue( + _ newValue: CKAsset, + data: @autoclosure () -> [UInt8], + forKey key: CKRecord.FieldKey, + at userModificationDate: Date + ) -> Bool { + // TODO: This should be 'encryptedValues[' + guard self[at: key] < userModificationDate else { return false } + if (self[key] as? CKAsset)?.fileURL != newValue.fileURL { + self[key] = newValue + // TODO: This should be 'encryptedValues[' + self[at: key] = userModificationDate + return true } + return false } + @discardableResult package func removeValue( forKey key: CKRecord.FieldKey, - at userModificationDate: Date? - ) { + at userModificationDate: Date + ) -> Bool { + // TODO: 'self[at: key]' should always be 'encryptedValues[at: key]' + guard Swift.max(encryptedValues[at: key], self[at: key]) < userModificationDate + else { return false } if encryptedValues[key] != nil { encryptedValues[key] = nil - encryptedValues[ - "\(String.sqliteDataCloudKitSchemaName)_userModificationDate_\(key)" - ] = userModificationDate + encryptedValues[at: key] = userModificationDate + return true + } else if self[key] != nil { + self[key] = nil + self[at: key] = userModificationDate + return true } + return false } - package func update(with row: T, userModificationDate: Date?) { + package func update(with row: T, userModificationDate: Date) { self.userModificationDate = userModificationDate for column in T.TableColumns.allColumns { func open(_ column: some TableColumnExpression) { @@ -159,41 +242,42 @@ extension CKRecord { } } - package var userModificationDate: Date? { - get { encryptedValues[Self.userModificationDateKey] as? Date } - set { encryptedValues[Self.userModificationDateKey] = newValue } + func versionedKeys() -> [FieldKey] { + allKeys() + .filter { $0.hasPrefix("\(Self.userModificationDateKey)_") } + .map { String($0.dropFirst("\(Self.userModificationDateKey)_".count)) } } - package var userModificationDates: [String: Date] { - var userModificationDates: [String: Date] = [:] - for key in encryptedValues.allKeys() { - guard - key.hasPrefix("\(CKRecord.userModificationDateKey)_"), - let date = encryptedValues[key] as? Date - else { continue } - let key = String(key.dropFirst(CKRecord.userModificationDateKey.count + 1)) - userModificationDates[key] = date + package func update(with other: CKRecord, columnNames: inout [String]) { + typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable + + self.userModificationDate = other.userModificationDate + for key in other.versionedKeys() { + let didSet = if let value = other[key] as? CKAsset { + // TODO: This should be 'other.encryptedValues[' + setValue(value, forKey: key, at: other[at: key]) + } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { + setValue(value, forKey: key, at: other.encryptedValues[at: key]) + } else if other.encryptedValues[key] == nil { + removeValue(forKey: key, at: other.encryptedValues[at: key]) + } else { + false + } + if didSet { + columnNames.removeAll(where: { $0 == key }) + } } - return userModificationDates } - private static let userModificationDateKey = - "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - init?(record: CKRecord) { - let recordName = RecordName(recordID: record.recordID) - guard let recordName - else { return nil } - self.init( - recordType: record.recordType, - recordName: recordName, - lastKnownServerRecord: record, - userModificationDate: record.userModificationDate - ) + package var userModificationDate: Date { + get { encryptedValues[Self.userModificationDateKey] as? Date ?? .distantPast } + set { + encryptedValues[Self.userModificationDateKey] = Swift.max(userModificationDate, newValue) + } } + + fileprivate static let userModificationDateKey = + "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" } extension __CKRecordObjCValue { diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 8ea96d38..45664190 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -40,8 +40,9 @@ func defaultMetadatabase( "recordName" TEXT NOT NULL PRIMARY KEY, "parentRecordName" TEXT, "lastKnownServerRecord" BLOB, + "_lastKnownServerRecordAllFields" BLOB, "share" BLOB, - "userModificationDate" TEXT + "userModificationDate" TEXT NOT NULL DEFAULT (\(.datetime())) ) STRICT """ ) @@ -77,3 +78,10 @@ func defaultMetadatabase( try migrator.migrate(metadatabase) return metadatabase } + + +extension QueryFragment { + static func datetime() -> Self { + Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c33c86e4..9c977955 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -562,7 +562,18 @@ guard let recordName = SyncMetadata.RecordName(recordID: recordID), - let metadata = await metadataFor(recordName: recordName) + let (metadata, allFields) = await withErrorReporting( + .sqliteDataCloudKitFailure, + catching: { + try await metadatabase.read { db in + try SyncMetadata + .find(recordName) + .select { ($0, $0._lastKnownServerRecordAllFields) } + .fetchOne(db) + } + } + ) + ?? nil else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) return nil @@ -589,7 +600,7 @@ } let record = - metadata.lastKnownServerRecord + allFields ?? CKRecord( recordType: metadata.recordType, recordID: recordID @@ -792,10 +803,20 @@ withErrorReporting { try Self.$isUpdatingWithServerRecord.withValue(true) { try database.write { db in - try SyncMetadata - .find(recordName) - .update { $0.lastKnownServerRecord = nil } - .execute(db) + try SQLQueryExpression( + """ + UPDATE \(SyncMetadata.self) SET + \(quote: SyncMetadata.lastKnownServerRecord.name) = NULL, + "_lastKnownServerRecordAllFields" = NULL, + \(quote: SyncMetadata.share.name) = NULL + WHERE \(SyncMetadata.recordName) = \(recordName) + """ + ) + .execute(db) + // try SyncMetadata + // .find(recordName) + // .update { $0.lastKnownServerRecord = nil } + // .execute(db) } } } @@ -888,7 +909,8 @@ withErrorReporting(.sqliteDataCloudKitFailure) { guard let table = tablesByName[serverRecord.recordType] else { - // TODO: Should we be reporting this? What if another device makes changes to a table this device doesn't know about? + // TODO: Should we be reporting this? + // What if another device makes changes to a table this device doesn't know about? reportIssue( .sqliteDataCloudKitFailure.appending( """ @@ -902,29 +924,25 @@ else { return } - let userModificationDate = - try metadatabase.read { db in - try SyncMetadata.find(recordName).select(\.userModificationDate).fetchOne( - db - ) - } - ?? nil guard - let userModificationDate, - userModificationDate > serverRecord.userModificationDate ?? .distantPast - else { - // TODO: This should be fetched early and held onto (like 'ForeignKey') - let columnNames = try database.read { db in - try SQLQueryExpression( - """ - SELECT "name" - FROM pragma_table_info(\(bind: table.tableName)) - """, - as: String.self - ) - .fetchAll(db) + let (metadata, allFields) = + try metadatabase.read({ db in + try SyncMetadata + .find(recordName) + .select { ($0, $0._lastKnownServerRecordAllFields) } + .fetchOne(db) + }) + ?? nil + else { return } + + func open>(_: T.Type) throws { + var columnNames = T.TableColumns.allColumns.map(\.name) + + if let allFields { + serverRecord.update(with: allFields, columnNames: &columnNames) } - var query: QueryFragment = "INSERT INTO \(table) (" + + var query: QueryFragment = "INSERT INTO \(T.self) (" query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) query.append(") VALUES (") let encryptedValues = serverRecord.encryptedValues @@ -940,15 +958,11 @@ } .joined(separator: ", ") ) - func open(_: T.Type) -> String { - T.columns.primaryKey.name - } - let primaryKeyName = open(table) - query.append(") ON CONFLICT(\(quote: primaryKeyName)) DO UPDATE SET ") + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET ") query.append( columnNames - .filter { columnName in columnName != primaryKeyName } + .filter { columnName in columnName != T.columns.primaryKey.name } .map { """ \(quote: $0) = "excluded".\(quote: $0) @@ -958,24 +972,53 @@ ) // TODO: Append more ON CONFLICT clauses for each unique constraint? // TODO: Use WHERE to scope the update? - guard let metadata = SyncMetadata(record: serverRecord) + guard let recordName = SyncMetadata.RecordName(recordID: serverRecord.recordID) else { reportIssue("???") return } try database.write { db in try SQLQueryExpression(query).execute(db) - try SyncMetadata - .insert { - metadata - } onConflictDoUpdate: { - $0.lastKnownServerRecord = serverRecord - $0.userModificationDate = serverRecord.userModificationDate - } - .execute(db) + try SQLQueryExpression( + """ + INSERT INTO \(SyncMetadata.self) ( + \(quote: SyncMetadata.recordType.name), + \(quote: SyncMetadata.recordName.name), + \(quote: SyncMetadata.lastKnownServerRecord.name), + "_lastKnownServerRecordAllFields", + \(quote: SyncMetadata.userModificationDate.name) + ) VALUES ( + \(serverRecord.recordType), + \(recordName), + \(serverRecord, as: CKRecord.SystemFieldsRepresentation.self), + \(serverRecord, as: CKRecord.AllFieldsRepresentation.self), + \(serverRecord.userModificationDate) + ) + ON CONFLICT DO UPDATE SET + \(quote: SyncMetadata.lastKnownServerRecord.name) = "excluded".\(quote: SyncMetadata.lastKnownServerRecord.name) + "_lastKnownServerRecordAllFields" = "excluded"."_lastKnownServerRecordAllFields" + \(quote: SyncMetadata.userModificationDate.name) = "excluded".\(quote: SyncMetadata.userModificationDate.name) + """ + ) + // TODO: Can't use '_lastKnownServerRecordAllFields' yet + // try SyncMetadata + // .insert { + // ($0.recordType, $0.recordName, $0.lastKnownServerRecord, $0.userModificationDate) + // } values: { + // ( + // serverRecord.recordType, + // recordName, + // serverRecord, + // serverRecord.userModificationDate + // ) + // } onConflictDoUpdate: { + // $0.lastKnownServerRecord = serverRecord + // $0.userModificationDate = serverRecord.userModificationDate + // } + // .execute(db) } - return } + try open(table) } } } @@ -991,10 +1034,19 @@ func updateLastKnownServerRecord() { withErrorReporting(.sqliteDataCloudKitFailure) { try database.write { db in - try SyncMetadata - .find(recordName) - .update { $0.lastKnownServerRecord = record } - .execute(db) + try SQLQueryExpression( + """ + UPDATE \(SyncMetadata.self) SET + \(quote: SyncMetadata.lastKnownServerRecord.name) = \(record, as: CKRecord.SystemFieldsRepresentation.self), + "_lastKnownServerRecordAllFields" = \(record, as: CKRecord.AllFieldsRepresentation.self) + WHERE \(SyncMetadata.recordName) = \(recordName) + """ + ) + .execute(db) + // try SyncMetadata + // .find(recordName) + // .update { $0.lastKnownServerRecord = record } + // .execute(db) } } } @@ -1296,4 +1348,14 @@ result[table.type.tableName] = result.count } } + +// TODO: Remove when available on 'main' +extension QueryFragment.StringInterpolation { + public mutating func appendInterpolation( + _ queryOutput: QueryValue.QueryOutput, + as representableType: QueryValue.Type + ) { + appendInterpolation(QueryValue(queryOutput: queryOutput)) + } +} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index a6a2bab2..3fc4f1f4 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -10,7 +10,7 @@ extension SyncMetadata { public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public var primaryKey: StructuredQueriesCore.TableColumn { self.recordName } @@ -26,7 +26,7 @@ extension SyncMetadata { public var parentRecordName: RecordName? public var lastKnownServerRecord: CKRecord? public var share: CKShare? - public var userModificationDate: Date? + public var userModificationDate: Date public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Draft public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) @@ -34,7 +34,7 @@ extension SyncMetadata { public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] } @@ -49,7 +49,7 @@ extension SyncMetadata { self.parentRecordName = try decoder.decode(RecordName.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) - self.userModificationDate = try decoder.decode(Date.self) + let userModificationDate = try decoder.decode(Date.self) guard let recordType else { throw QueryDecodingError.missingRequiredColumn } @@ -59,9 +59,13 @@ extension SyncMetadata { guard let share else { throw QueryDecodingError.missingRequiredColumn } + guard let userModificationDate else { + throw QueryDecodingError.missingRequiredColumn + } self.recordType = recordType self.lastKnownServerRecord = lastKnownServerRecord self.share = share + self.userModificationDate = userModificationDate } public init(_ other: SyncMetadata) { @@ -78,7 +82,7 @@ extension SyncMetadata { parentRecordName: RecordName? = nil, lastKnownServerRecord: CKRecord? = nil, share: CKShare? = nil, - userModificationDate: Date? = nil + userModificationDate: Date ) { self.recordType = recordType self.recordName = recordName @@ -99,7 +103,7 @@ extension SyncMetadata { self.parentRecordName = try decoder.decode(RecordName.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) - self.userModificationDate = try decoder.decode(Date.self) + let userModificationDate = try decoder.decode(Date.self) guard let recordType else { throw QueryDecodingError.missingRequiredColumn } @@ -112,10 +116,14 @@ extension SyncMetadata { guard let share else { throw QueryDecodingError.missingRequiredColumn } + guard let userModificationDate else { + throw QueryDecodingError.missingRequiredColumn + } self.recordType = recordType self.recordName = recordName self.lastKnownServerRecord = lastKnownServerRecord self.share = share + self.userModificationDate = userModificationDate } } #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index ba6f16a5..75128082 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -45,7 +45,7 @@ public struct SyncMetadata: Hashable, Sendable { public var share: CKShare? /// The date the user last modified the record. - public var userModificationDate: Date? + public var userModificationDate: Date package init( recordType: String, @@ -53,7 +53,7 @@ public struct SyncMetadata: Hashable, Sendable { parentRecordName: RecordName? = nil, lastKnownServerRecord: CKRecord? = nil, share: CKShare? = nil, - userModificationDate: Date? = nil + userModificationDate: Date ) { self.recordType = recordType self.recordName = recordName @@ -121,6 +121,12 @@ extension SyncMetadata.TableColumns { public var parentRecordType: some QueryExpression { SQLQueryExpression("substr(\(parentRecordName), 38)") } + + package var _lastKnownServerRecordAllFields: some QueryExpression< + CKRecord?.AllFieldsRepresentation + > { + SQLQueryExpression("\(SyncMetadata.self).\(quote: "_lastKnownServerRecordAllFields")") + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 9f575a31..79c6a11e 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -51,13 +51,12 @@ extension SyncMetadata { #""new"."\#($0.from)" || ':' || '\#($0.table)'"# } ?? "NULL" return insert { - ($0.recordType, $0.recordName, $0.parentRecordName, $0.userModificationDate) + ($0.recordType, $0.recordName, $0.parentRecordName) } select: { Values( T.tableName, new.recordName, - SQLQueryExpression(#"\#(raw: parentForeignKey) AS "foreignKey""#), - .datetime() + SQLQueryExpression(#"\#(raw: parentForeignKey) AS "foreignKey""#) ) } onConflict: { $0.recordName @@ -163,10 +162,3 @@ extension QueryExpression where Self == SQLQueryExpression<()> { private func isUpdatingWithServerRecord() -> SQLQueryExpression { SQLQueryExpression("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") } - -extension QueryExpression { - fileprivate static func datetime>() -> Self - where Self == SQLQueryExpression { - Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") - } -} diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index e806b22c..a74835d9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -686,12 +686,16 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) let userModificationDate = now.addingTimeInterval(60) - record.encryptedValues.setValue("Buy milk", forKey: "title", at: userModificationDate) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) record.userModificationDate = userModificationDate _ = syncEngine.private.database.modifyRecords(saving: [record]) - try await database.asyncWrite { db in - try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + try await withDependencies { + $0.date.now = now.addingTimeInterval(30) + } operation: { + try await database.asyncWrite { db in + try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + } } await syncEngine.processBatch() @@ -736,6 +740,50 @@ extension BaseCloudKitTests { ) """ } + + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 1, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Buy milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:00.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 3abdcdc4..b1e23744 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -36,7 +36,8 @@ extension BaseCloudKitTests { try SyncMetadata.insert { SyncMetadata( recordType: UnrecognizedTable.tableName, - recordName: SyncMetadata.RecordName(UnrecognizedTable.self, id: UUID(1)) + recordName: SyncMetadata.RecordName(UnrecognizedTable.self, id: UUID(1)), + userModificationDate: .distantPast ) } .execute(db) @@ -64,7 +65,8 @@ extension BaseCloudKitTests { try SyncMetadata.insert { SyncMetadata( recordType: RemindersList.tableName, - recordName: SyncMetadata.RecordName(RemindersList.self, id: UUID(1)) + recordName: SyncMetadata.RecordName(RemindersList.self, id: UUID(1)), + userModificationDate: .distantPast ) } .execute(db) diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 45dd70d1..fc7e1236 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -302,8 +302,8 @@ final class MockCloudDatabase: CloudDatabase { CKError( .serverRecordChanged, userInfo: [ - CKRecordChangedErrorServerRecordKey: existingRecord as Any, - CKRecordChangedErrorClientRecordKey: recordToSave, + CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, + CKRecordChangedErrorClientRecordKey: recordToSave.copy(), ] ) ) @@ -324,8 +324,8 @@ final class MockCloudDatabase: CloudDatabase { CKError( .serverRejectedRequest, userInfo: [ - CKRecordChangedErrorServerRecordKey: existingRecord as Any, - CKRecordChangedErrorClientRecordKey: recordToSave, + CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, + CKRecordChangedErrorClientRecordKey: recordToSave.copy(), ] ) ) @@ -607,7 +607,7 @@ extension SyncEngine { ) if !syncEngine.state.pendingRecordZoneChanges.isEmpty { - fatalError("Should we add the option to immediately process any enqueued changes?") + // fatalError("Should we add the option to immediately process any enqueued changes?") } } From e83f0968450181c62f2f5c672962630e5062817b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 1 Jul 2025 15:39:41 -0700 Subject: [PATCH 283/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 ++-- Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b7abb3a4..9fc63ff0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -992,8 +992,8 @@ \(serverRecord.userModificationDate) ) ON CONFLICT DO UPDATE SET - \(quote: SyncMetadata.lastKnownServerRecord.name) = "excluded".\(quote: SyncMetadata.lastKnownServerRecord.name) - "_lastKnownServerRecordAllFields" = "excluded"."_lastKnownServerRecordAllFields" + \(quote: SyncMetadata.lastKnownServerRecord.name) = "excluded".\(quote: SyncMetadata.lastKnownServerRecord.name), + "_lastKnownServerRecordAllFields" = "excluded"."_lastKnownServerRecordAllFields", \(quote: SyncMetadata.userModificationDate.name) = "excluded".\(quote: SyncMetadata.userModificationDate.name) """ ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 30938fbe..340fa318 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -635,7 +635,7 @@ extension BaseCloudKitTests { } @Test func merge() async throws { - try await database.asyncWrite { db in + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "") Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) @@ -693,7 +693,7 @@ extension BaseCloudKitTests { try await withDependencies { $0.date.now = now.addingTimeInterval(30) } operation: { - try await database.asyncWrite { db in + try await userDatabase.userWrite { db in try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) } } From dec434c371bc5d0791d5feb2fd19c4c731b92186 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 1 Jul 2025 22:57:45 -0700 Subject: [PATCH 284/581] wip --- .../CloudKit/Metadatabase.swift | 1 - .../SharingGRDBCore/CloudKit/SyncEngine.swift | 134 +++++++----------- .../CloudKit/SyncMetadata.swift | 15 +- .../CloudKitTests/CloudKitTests.swift | 58 +++++--- 4 files changed, 108 insertions(+), 100 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 45664190..d9e02863 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -79,7 +79,6 @@ func defaultMetadatabase( return metadatabase } - extension QueryFragment { static func datetime() -> Self { Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 9fc63ff0..34a85c87 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -579,7 +579,7 @@ } } ) - ?? nil + ?? nil else { syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) return nil @@ -732,9 +732,9 @@ await refreshLastKnownServerRecord(record) } if let shareReference = record.share, - // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in - let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), - let share = shareRecord as? CKShare + // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in + let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), + let share = shareRecord as? CKShare { await withErrorReporting { try await cacheShare(share) @@ -766,9 +766,9 @@ // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? reportIssue( .sqliteDataCloudKitFailure.appending( - """ - : No table to delete from: "\(recordType)" - """ + """ + : No table to delete from: "\(recordType)" + """ ) ) } @@ -802,23 +802,16 @@ func clearServerRecord() { withErrorReporting { try userDatabase.write { db in - try SQLQueryExpression( - """ - UPDATE \(SyncMetadata.self) SET - \(quote: SyncMetadata.lastKnownServerRecord.name) = NULL, - "_lastKnownServerRecordAllFields" = NULL, - \(quote: SyncMetadata.share.name) = NULL - WHERE \(SyncMetadata.recordName) = \(recordName) - """ - ) - .execute(db) - // try SyncMetadata - // .find(recordName) - // .update { $0.lastKnownServerRecord = nil } - // .execute(db) + try SyncMetadata + .find(recordName) + .update { + $0.lastKnownServerRecord = nil + $0._lastKnownServerRecordAllFields = nil + } + .execute(db) } } - } + } switch failedRecordSave.error.code { case .serverRecordChanged: @@ -976,44 +969,29 @@ } try userDatabase.write { db in try SQLQueryExpression(query).execute(db) - try SQLQueryExpression( - """ - INSERT INTO \(SyncMetadata.self) ( - \(quote: SyncMetadata.recordType.name), - \(quote: SyncMetadata.recordName.name), - \(quote: SyncMetadata.lastKnownServerRecord.name), - "_lastKnownServerRecordAllFields", - \(quote: SyncMetadata.userModificationDate.name) - ) VALUES ( - \(bind: serverRecord.recordType), - \(recordName), - \(serverRecord, as: CKRecord.SystemFieldsRepresentation.self), - \(serverRecord, as: CKRecord.AllFieldsRepresentation.self), - \(serverRecord.userModificationDate) - ) - ON CONFLICT DO UPDATE SET - \(quote: SyncMetadata.lastKnownServerRecord.name) = "excluded".\(quote: SyncMetadata.lastKnownServerRecord.name), - "_lastKnownServerRecordAllFields" = "excluded"."_lastKnownServerRecordAllFields", - \(quote: SyncMetadata.userModificationDate.name) = "excluded".\(quote: SyncMetadata.userModificationDate.name) - """ - ) - .execute(db) - // TODO: Can't use '_lastKnownServerRecordAllFields' yet - // try SyncMetadata - // .insert { - // ($0.recordType, $0.recordName, $0.lastKnownServerRecord, $0.userModificationDate) - // } values: { - // ( - // serverRecord.recordType, - // recordName, - // serverRecord, - // serverRecord.userModificationDate - // ) - // } onConflictDoUpdate: { - // $0.lastKnownServerRecord = serverRecord - // $0.userModificationDate = serverRecord.userModificationDate - // } - // .execute(db) + try SyncMetadata + .insert { + ( + $0.recordType, + $0.recordName, + $0.lastKnownServerRecord, + $0._lastKnownServerRecordAllFields, + $0.userModificationDate + ) + } values: { + ( + serverRecord.recordType, + recordName, + serverRecord, + serverRecord, + serverRecord.userModificationDate + ) + } onConflictDoUpdate: { + $0.lastKnownServerRecord = serverRecord + $0._lastKnownServerRecordAllFields = serverRecord + $0.userModificationDate = serverRecord.userModificationDate + } + .execute(db) } } try open(table) @@ -1030,19 +1008,13 @@ func updateLastKnownServerRecord() { withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in - try SQLQueryExpression( - """ - UPDATE \(SyncMetadata.self) SET - \(quote: SyncMetadata.lastKnownServerRecord.name) = \(record, as: CKRecord.SystemFieldsRepresentation.self), - "_lastKnownServerRecordAllFields" = \(record, as: CKRecord.AllFieldsRepresentation.self) - WHERE \(SyncMetadata.recordName) = \(recordName) - """ - ) - .execute(db) - // try SyncMetadata - // .find(recordName) - // .update { $0.lastKnownServerRecord = record } - // .execute(db) + try SyncMetadata + .find(recordName) + .update { + $0.lastKnownServerRecord = record + $0._lastKnownServerRecordAllFields = record + } + .execute(db) } } } @@ -1345,13 +1317,13 @@ } } -// TODO: Remove when available on 'main' -extension QueryFragment.StringInterpolation { - public mutating func appendInterpolation( - _ queryOutput: QueryValue.QueryOutput, - as representableType: QueryValue.Type - ) { - appendInterpolation(QueryValue(queryOutput: queryOutput)) + // TODO: Remove when available on 'main' + extension QueryFragment.StringInterpolation { + public mutating func appendInterpolation( + _ queryOutput: QueryValue.QueryOutput, + as representableType: QueryValue.Type + ) { + appendInterpolation(QueryValue(queryOutput: queryOutput)) + } } -} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 75128082..ae74b5d8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -36,10 +36,13 @@ public struct SyncMetadata: Hashable, Sendable { /// ``` public var parentRecordName: RecordName? + // TODO: lastKnownSystemFields /// The last known `CKRecord` received from the server. // @Column(as: CKRecord?.DataRepresentation.self) public var lastKnownServerRecord: CKRecord? + // TODO: _lastKnownAllFields + /// The `CKShare` associated with this record, if it is shared. // @Column(as: CKShare?.ShareDataRepresentation.self) public var share: CKShare? @@ -47,6 +50,10 @@ public struct SyncMetadata: Hashable, Sendable { /// The date the user last modified the record. public var userModificationDate: Date + var _lastKnownServerRecordAllFields: CKRecord? { + fatalError() + } + package init( recordType: String, recordName: RecordName, @@ -122,10 +129,14 @@ extension SyncMetadata.TableColumns { SQLQueryExpression("substr(\(parentRecordName), 38)") } - package var _lastKnownServerRecordAllFields: some QueryExpression< + package var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< + SyncMetadata, CKRecord?.AllFieldsRepresentation > { - SQLQueryExpression("\(SyncMetadata.self).\(quote: "_lastKnownServerRecordAllFields")") + StructuredQueriesCore.TableColumn( + "_lastKnownServerRecordAllFields", + keyPath: \._lastKnownServerRecordAllFields + ) } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 340fa318..2fec36cd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -132,7 +132,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -179,7 +181,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -259,7 +263,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -271,11 +277,15 @@ extension BaseCloudKitTests { """ } - try await userDatabase.userWrite { db in - try RemindersList - .find(UUID(1)) - .update { $0.title = "Work" } - .execute(db) + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList + .find(UUID(1)) + .update { $0.title = "Work" } + .execute(db) + } } await syncEngine.processBatch() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { @@ -291,7 +301,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Work", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) ) ] ), @@ -347,7 +359,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -400,7 +414,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Work", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -434,7 +450,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -489,7 +507,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -523,7 +543,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -611,7 +633,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(2:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -620,7 +644,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000002", title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), From 2023b38919a18f0830d4cd8beeea89d25d56932f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 1 Jul 2025 23:23:00 -0700 Subject: [PATCH 285/581] wip --- .../CloudKitTests/ForeignKeyTests.swift | 133 +++++++++++++----- .../CloudKitTests/MetadataTests.swift | 73 +++++++--- .../CloudKitTests/NewTableSyncTests.swift | 10 +- .../NextRecordZoneChangeBatchTests.swift | 23 ++- .../CloudKitTests/SharingTests.swift | 29 ++-- .../CloudKitTests/SyncEngineTests.swift | 2 +- .../CloudKitTests/TriggerTests.swift | 72 +++++----- 7 files changed, 231 insertions(+), 111 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 5ae760e6..6f198472 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -36,7 +36,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -47,7 +51,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Walk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [2]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -56,7 +64,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -68,8 +78,12 @@ extension BaseCloudKitTests { """ } - try await userDatabase.userWrite { db in - try RemindersList.find(UUID(1)).delete().execute(db) + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(UUID(1)).delete().execute(db) + } } try await userDatabase.userRead { db in try #expect(Reminder.all.fetchAll(db) == []) @@ -115,7 +129,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -123,7 +139,8 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -135,8 +152,12 @@ extension BaseCloudKitTests { """ } - try await userDatabase.userWrite { db in - try Parent.find(UUID(1)).delete().execute(db) + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Parent.find(UUID(1)).delete().execute(db) + } } try await userDatabase.userRead { db in try expectNoDifference( @@ -160,7 +181,9 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:32:30.000Z) ) ] ), @@ -199,7 +222,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -210,7 +237,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Walk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [2]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -219,7 +250,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -231,8 +264,12 @@ extension BaseCloudKitTests { """ } - try await userDatabase.userWrite { db in - try RemindersList.find(UUID(1)).update { $0.id = UUID(9) }.execute(db) + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(UUID(1)).update { $0.id = UUID(9) }.execute(db) + } } try await userDatabase.userRead { db in try expectNoDifference( @@ -261,7 +298,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000009", title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -272,7 +313,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000009", title: "Walk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [2]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -281,7 +326,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [3]: CKRecord( recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -290,7 +337,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000009", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) ) ] ), @@ -326,7 +375,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -334,7 +385,8 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -345,10 +397,13 @@ extension BaseCloudKitTests { ) """ } - let error = #expect(throws: DatabaseError.self) { - try self.userDatabase.userWrite { db in - try Parent.find(UUID(1)).delete().execute(db) + try withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try self.userDatabase.userWrite { db in + try Parent.find(UUID(1)).delete().execute(db) + } } } #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) @@ -375,7 +430,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -383,7 +440,8 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -419,7 +477,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -427,7 +487,8 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -438,10 +499,13 @@ extension BaseCloudKitTests { ) """ } - let error = #expect(throws: DatabaseError.self) { - try self.userDatabase.userWrite { db in - try Parent.find(UUID(1)).update { $0.id = UUID(2) }.execute(db) + try withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try self.userDatabase.userWrite { db in + try Parent.find(UUID(1)).update { $0.id = UUID(2) }.execute(db) + } } } #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) @@ -468,7 +532,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -476,7 +542,8 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 7c2c034e..ac5f1657 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -36,7 +36,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -45,7 +49,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [2]: CKRecord( recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -54,7 +60,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000002", title: "Work", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -75,18 +83,22 @@ extension BaseCloudKitTests { #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(1))) } - try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)) - .update { $0.remindersListID = UUID(2) } - .execute(db) - let reminderMetadata = try #require( - try SyncMetadata - .find(Reminder.recordName(for: UUID(1))) - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(2))) + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)) + .update { $0.remindersListID = UUID(2) } + .execute(db) + let reminderMetadata = try #require( + try SyncMetadata + .find(Reminder.recordName(for: UUID(1))) + .fetchOne(db) + ) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(2))) + } } - + await syncEngine.processBatch() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ @@ -103,7 +115,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000002", title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -112,7 +128,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [2]: CKRecord( recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -121,7 +139,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000002", title: "Work", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -159,7 +179,10 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", reminderID: "00000000-0000-0000-0000-000000000001", tagID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_reminderID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_tagID: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -170,7 +193,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [2]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -179,7 +206,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [3]: CKRecord( recordID: CKRecord.ID(1:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -188,7 +217,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "weekend", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 574cb8a1..e88053a7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -36,7 +36,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Write blog post", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -45,7 +49,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 10603525..0b9bf860 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -110,7 +110,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -148,7 +150,11 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Get milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -157,7 +163,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -193,7 +201,10 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", position: 42, remindersListID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_position: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -202,7 +213,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index a6db50ad..3ca401be 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -89,6 +89,21 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Get milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, @@ -102,19 +117,7 @@ extension BaseCloudKitTests { ), sharedCloudDatabase: MockCloudDatabase( databaseScope: .shared, - storage: [ - [0]: 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: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Get milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) - ) - ] + storage: [] ) ) """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index 2af3beeb..26003df9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -24,7 +24,7 @@ extension BaseCloudKitTests { // metadatabaseURL: URL.temporaryDirectory, // tables: [] // ) -// } + } #expect( String(decoding: try #require(result).standardOutputContent, as: UTF8.self) == "Foreign key support must be disabled to synchronize with CloudKit." diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index c1a9f6e6..a51f14a7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -126,8 +126,8 @@ extension BaseCloudKitTests { AFTER INSERT ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -137,8 +137,8 @@ extension BaseCloudKitTests { AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -148,8 +148,8 @@ extension BaseCloudKitTests { AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -159,8 +159,8 @@ extension BaseCloudKitTests { AFTER INSERT ON "parents" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -170,8 +170,8 @@ extension BaseCloudKitTests { AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -181,8 +181,8 @@ extension BaseCloudKitTests { AFTER INSERT ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -192,8 +192,8 @@ extension BaseCloudKitTests { AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -203,8 +203,8 @@ extension BaseCloudKitTests { AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -214,8 +214,8 @@ extension BaseCloudKitTests { AFTER INSERT ON "tags" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -225,8 +225,8 @@ extension BaseCloudKitTests { AFTER UPDATE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -236,8 +236,8 @@ extension BaseCloudKitTests { AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -247,8 +247,8 @@ extension BaseCloudKitTests { AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -258,8 +258,8 @@ extension BaseCloudKitTests { AFTER UPDATE ON "parents" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -269,8 +269,8 @@ extension BaseCloudKitTests { AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -280,8 +280,8 @@ extension BaseCloudKitTests { AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -291,8 +291,8 @@ extension BaseCloudKitTests { AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -302,8 +302,8 @@ extension BaseCloudKitTests { AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END @@ -313,8 +313,8 @@ extension BaseCloudKitTests { AFTER UPDATE ON "tags" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName", "userModificationDate") - SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey", sqlitedata_icloud_datetime() + ("recordType", "recordName", "parentRecordName") + SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey" ON CONFLICT ("recordName") DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END From ab7160c571cd2ac4733a1d15d562b498e3e69e81 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 2 Jul 2025 09:09:28 -0400 Subject: [PATCH 286/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d87890da..4ba4ab51 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -362,7 +362,7 @@ return } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) - // TODO: do something with the CKShare returned? + // TODO: do something with the CKShare returned? save it in SyncMetadata? _ = try await container.accept(metadata) try await syncEngines.shared?.fetchChanges( .init( @@ -711,6 +711,7 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { + let shares: [CKShare] = [] for record in modifications { if let share = record as? CKShare { await withErrorReporting { From 79f559f53cccd48e9328ce107c6b9faed19a2042 Mon Sep 17 00:00:00 2001 From: Sean Woodward Date: Wed, 2 Jul 2025 08:23:06 -0500 Subject: [PATCH 287/581] Thanks to [@Patrick's](https://pointfreecommunity.slack.com/team/U07E2FYTW8P) [post](https://pointfreecommunity.slack.com/team/U07E2FYTW8P) --- Examples/CloudKitDemo/Schema.swift | 2 +- Examples/Reminders/Schema.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index aa011a0a..5e0bf003 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -14,8 +14,8 @@ func appDatabase() throws -> any DatabaseWriter { var configuration = Configuration() configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") #if DEBUG - try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") db.trace(options: .profile) { if context == .live { logger.debug("\($0.expandedDescription)") diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index b53c08f2..6da798b0 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -105,8 +105,8 @@ func appDatabase() throws -> any DatabaseWriter { var configuration = Configuration() configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in - #if DEBUG try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.Reminders") + #if DEBUG db.trace(options: .profile) { if context == .live { logger.debug("\($0.expandedDescription)") From 578b608694b542c10c0b31a4c526af508c8c501e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 2 Jul 2025 09:53:30 -0400 Subject: [PATCH 288/581] validate only the tables being synchronized --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 10 +-- .../SyncEngineValidationTests.swift | 66 ++++++++++++++++--- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index e2203e30..46f32409 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1174,21 +1174,23 @@ tables: [any PrimaryKeyedTable.Type], userDatabase: UserDatabase ) throws { + let tableNames = Set(tables.map { $0.tableName }) try userDatabase.write { db in let triggers = try SQLQueryExpression( """ - SELECT "name", "sql" + SELECT "name", "tbl_name", "sql" FROM "sqlite_master" WHERE "type" = 'trigger' UNION - SELECT "name", "sql" + SELECT "name", "tbl_name", "sql" FROM "sqlite_temp_master" WHERE "type" = 'trigger' """, - as: (String, String).self + as: (String, String, String).self ) .fetchAll(db) - let invalidTriggers = triggers.compactMap { name, sql in + .filter { _, tableName, _ in tableNames.contains(tableName) } + let invalidTriggers = triggers.compactMap { name, _, sql in let isValid = sql .lowercased() diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 23e1ecd6..d72871bc 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -13,7 +13,9 @@ extension BaseCloudKitTests { func userTriggerValidation() async throws { let error = try await #require( #expect(throws: InvalidUserTriggers.self) { - let database = try DatabaseQueue() + var configuration = Configuration() + configuration.foreignKeysEnabled = false + let database = try DatabaseQueue(configuration: configuration) try await database.write { db in try #sql( """ @@ -24,21 +26,25 @@ extension BaseCloudKitTests { """ ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TRIGGER "non_temporary_trigger" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN SELECT 1; END - """) + """ + ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TEMPORARY TRIGGER "temporary_trigger" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN SELECT 1; END - """) + """ + ) .execute(db) } let _ = try await SyncEngine.init( @@ -48,9 +54,7 @@ extension BaseCloudKitTests { ), userDatabase: UserDatabase(database: database), metadatabaseURL: URL.temporaryDirectory.appending(path: UUID().uuidString), - tables: [ - RemindersList.self - ] + tables: [RemindersList.self] ) } ) @@ -64,5 +68,51 @@ extension BaseCloudKitTests { ) ) } + + @Test func doNotValidateTriggersOnNonSyncedTables() async throws { + var configuration = Configuration() + configuration.foreignKeysEnabled = false + let database = try DatabaseQueue(configuration: configuration) + try await database.write { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TRIGGER "non_temporary_trigger" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + SELECT 1; + END + """ + ) + .execute(db) + try #sql( + """ + CREATE TEMPORARY TRIGGER "temporary_trigger" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + SELECT 1; + END + """ + ) + .execute(db) + } + let _ = try await SyncEngine.init( + container: MockCloudContainer( + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + metadatabaseURL: URL.temporaryDirectory.appending(path: UUID().uuidString), + tables: [] + ) + } } } From 37dbe408cfd91b1fd55af0fe91f283b1c99836e4 Mon Sep 17 00:00:00 2001 From: Sean Woodward Date: Wed, 2 Jul 2025 10:07:06 -0500 Subject: [PATCH 289/581] Drop ForeignKey mention --- .../SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index a5db9eac..5d3bb526 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -280,9 +280,9 @@ CREATE TABLE "reminders" ( ) ``` -…and while the constraint will not be enforced, the "ON DELETE CASCADE" will still be implemented by -``ForeignKey`` triggers created in ``SyncEngine`` setup, i.e. when a reminders list is deleted, all -of its associated reminders will also be deleted, and everything will be synchronized to all devices. +…and while the constraint will not be enforced, the "ON DELETE CASCADE" will still be implemented +by triggers created in ``SyncEngine`` setup, i.e. when a reminders list is deleted, all of its +associated reminders will also be deleted, and everything will be synchronized to all devices. ## Record conflicts From 3ff17673ed29840e42b32b9f6269617012f902a1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 2 Jul 2025 11:15:53 -0400 Subject: [PATCH 290/581] Create FK triggers on all tables, not just sync'd tables. --- .../SharingGRDBCore/CloudKit/ForeignKey.swift | 84 +++++++++---------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 46 ++++++---- .../CloudKitTests/ForeignKeyTests.swift | 19 +++++ .../CloudKitTests/TriggerTests.swift | 16 +++- .../Internal/BaseCloudKitTests.swift | 2 +- Tests/SharingGRDBTests/Internal/Schema.swift | 17 +++- 6 files changed, 121 insertions(+), 63 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index 353e735b..e3069978 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -38,14 +38,14 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { case noAction = "NO ACTION" } - static func all( - _ table: T.Type + static func all( + _ tableName: String ) -> some StructuredQueriesCore.Statement { SQLQueryExpression( """ SELECT \(ForeignKey.columns) - FROM pragma_foreign_key_list(\(bind: table.tableName)) AS "foreign_keys" - JOIN pragma_table_info(\(bind: table.tableName)) AS "table_info" + FROM pragma_foreign_key_list(\(bind: tableName)) AS "foreign_keys" + JOIN pragma_table_info(\(bind: tableName)) AS "table_info" ON "foreign_keys"."from" = "table_info"."name" """, as: ForeignKey.self @@ -58,9 +58,9 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ } - func createTriggers, P: PrimaryKeyedTable>( - _: C.Type, - belongsTo _: P.Type, + func createTriggers( + _ childTableName: String, + belongsTo parentTableName: String, db: Database ) throws { switch onDelete { @@ -68,10 +68,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onDeleteCascade" - AFTER DELETE ON \(P.self) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteCascade" + AFTER DELETE ON \(quote: parentTableName, delimiter: .identifier) FOR EACH ROW BEGIN - DELETE FROM \(C.self) + DELETE FROM \(quote: childTableName, delimiter: .identifier) WHERE \(quote: from) = "old".\(quote: to); END """ @@ -82,11 +82,11 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onDeleteRestrict" - BEFORE DELETE ON \(P.self) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteRestrict" + BEFORE DELETE ON \(quote: parentTableName, delimiter: .identifier) FOR EACH ROW BEGIN SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM \(C.self) + FROM \(quote: childTableName, delimiter: .identifier) WHERE \(quote: from) = "old".\(quote: to); END """ @@ -98,7 +98,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ SELECT "dflt_value" - FROM pragma_table_info(\(bind: C.tableName)) + FROM pragma_table_info(\(bind: childTableName)) WHERE "name" = \(bind: from) """, as: String?.self @@ -108,10 +108,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onDeleteSetDefault" - AFTER DELETE ON \(P.self) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteSetDefault" + AFTER DELETE ON \(quote: parentTableName, delimiter: .identifier) FOR EACH ROW BEGIN - UPDATE \(C.self) + UPDATE \(quote: childTableName, delimiter: .identifier) SET \(quote: from) = \(raw: defaultValue ?? "NULL") WHERE \(quote: from) = "old".\(quote: to); END @@ -123,10 +123,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onDeleteSetNull" - AFTER DELETE ON \(P.self) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteSetNull" + AFTER DELETE ON \(quote: parentTableName, delimiter: .identifier) FOR EACH ROW BEGIN - UPDATE \(C.self) + UPDATE \(quote: childTableName, delimiter: .identifier) SET \(quote: from) = NULL WHERE \(quote: from) = "old".\(quote: to); END @@ -142,10 +142,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onUpdateCascade" - AFTER UPDATE ON \(P.self) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateCascade" + AFTER UPDATE ON \(quote: parentTableName, delimiter: .identifier) FOR EACH ROW BEGIN - UPDATE \(C.self) + UPDATE \(quote: childTableName, delimiter: .identifier) SET \(quote: from) = "new".\(quote: to) WHERE \(quote: from) = "old".\(quote: to); END @@ -157,11 +157,11 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onUpdateRestrict" - BEFORE UPDATE ON \(P.self) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateRestrict" + BEFORE UPDATE ON \(quote: parentTableName, delimiter: .identifier) FOR EACH ROW BEGIN SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM \(C.self) + FROM \(quote: childTableName, delimiter: .identifier) WHERE \(quote: from) = "old".\(quote: to); END """ @@ -173,7 +173,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ SELECT "dflt_value" - FROM pragma_table_info(\(bind: C.tableName)) + FROM pragma_table_info(\(bind: childTableName)) WHERE "name" = \(bind: from) """, as: String?.self @@ -183,10 +183,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onUpdateSetDefault" - AFTER UPDATE ON \(P.self) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateSetDefault" + AFTER UPDATE ON \(quote: parentTableName, delimiter: .identifier) FOR EACH ROW BEGIN - UPDATE \(C.self) + UPDATE \(quote: childTableName, delimiter: .identifier) SET \(quote: from) = \(raw: defaultValue ?? "NULL") WHERE \(quote: from) = "old".\(quote: to); END @@ -198,10 +198,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: C.tableName)_belongsTo_\(raw: P.tableName)_onUpdateSetNull" - AFTER UPDATE ON \(P.self) + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateSetNull" + AFTER UPDATE ON \(quote: parentTableName, delimiter: .identifier) FOR EACH ROW BEGIN - UPDATE \(C.self) + UPDATE \(quote: childTableName, delimiter: .identifier) SET \(quote: from) = NULL WHERE \(quote: from) = "old".\(quote: to); END @@ -213,13 +213,13 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { } } - func dropTriggers(for _: T.Type, db: Database) throws { + func dropTriggers(for childTableName: String, db: Database) throws { switch onDelete { case .cascade: try SQLQueryExpression( """ DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteCascade" + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onDeleteCascade" """ ) .execute(db) @@ -228,7 +228,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetNull" + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onDeleteSetNull" """ ) .execute(db) @@ -237,7 +237,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteSetDefault" + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onDeleteSetDefault" """ ) .execute(db) @@ -246,7 +246,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onDeleteRestrict" + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onDeleteRestrict" """ ) .execute(db) @@ -260,7 +260,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateCascade" + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onUpdateCascade" """ ) .execute(db) @@ -269,7 +269,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetNull" + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onUpdateSetNull" """ ) .execute(db) @@ -278,7 +278,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateSetDefault" + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onUpdateSetDefault" """ ) .execute(db) @@ -287,7 +287,7 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { try SQLQueryExpression( """ DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: T.tableName)_belongsTo_\(raw: table)_onUpdateRestrict" + "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onUpdateRestrict" """ ) .execute(db) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4ba4ab51..580104c7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -103,12 +103,20 @@ .map(\.type) self.tables = tables self.privateTables = privateTables + + let allTables = try userDatabase.read { db in + try SQLQueryExpression(""" + SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' + """, as: String.self) + .fetchAll(db) + } + self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = Dictionary( uniqueKeysWithValues: try userDatabase.read { db in - try tables.map { table -> (String, [ForeignKey]) in + try allTables.map { table -> (String, [ForeignKey]) in ( - table.tableName, + table, try ForeignKey.all(table).fetchAll(db) ) } @@ -166,6 +174,11 @@ db: db ) } + for (childTableName, foreignKeys) in foreignKeysByTableName { + for foreignKey in foreignKeys { + try foreignKey.createTriggers(childTableName, belongsTo: foreignKey.table, db: db) + } + } } let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) @@ -276,6 +289,11 @@ async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() try await userDatabase.write { db in + for (childTableName, foreignKeys) in self.foreignKeysByTableName { + for foreignKey in foreignKeys { + try foreignKey.dropTriggers(for: childTableName, db: db) + } + } for table in self.tables { try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) } @@ -393,14 +411,14 @@ try trigger.execute(db) } - let foreignKeys = foreignKeysByTableName[tableName] ?? [] - for foreignKey in foreignKeys { - guard let parent = tablesByName[foreignKey.table] else { - reportIssue("TODO") - continue - } - try foreignKey.createTriggers(Self.self, belongsTo: parent, db: db) - } +// let foreignKeys = foreignKeysByTableName[tableName] ?? [] +// for foreignKey in foreignKeys { +// guard let parent = tablesByName[foreignKey.table] else { +// reportIssue("TODO") +// continue +// } +// try foreignKey.createTriggers(Self.self, belongsTo: parent, db: db) +// } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -408,10 +426,10 @@ foreignKeysByTableName: [String: [ForeignKey]], db: Database ) throws { - let foreignKeys = foreignKeysByTableName[tableName] ?? [] - for foreignKey in foreignKeys.reversed() { - try foreignKey.dropTriggers(for: Self.self, db: db) - } +// let foreignKeys = foreignKeysByTableName[tableName] ?? [] +// for foreignKey in foreignKeys.reversed() { +// try foreignKey.dropTriggers(for: Self.self, db: db) +// } for trigger in metadataTriggers(parentForeignKey: nil).reversed() { try trigger.drop().execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 5ae760e6..a95091cd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -488,5 +488,24 @@ extension BaseCloudKitTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func nonSyncTable() async throws { + try await userDatabase.userWrite { db in + try db.seed { + LocalUser(id: UUID(1), name: "Blob", parentID: nil) + LocalUser(id: UUID(2), name: "Blob Jr", parentID: UUID(1)) + } + } + try await self.userDatabase.userWrite { db in + try LocalUser.find(UUID(1)).delete().execute(db) + } + try await userDatabase.userRead { db in + try expectNoDifference( + LocalUser.all.fetchAll(db), + [] + ) + } + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index c1a9f6e6..f0606c66 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -374,6 +374,14 @@ extension BaseCloudKitTests { END """, [36]: """ + CREATE TRIGGER "sqlitedata_icloud_localUsers_belongsTo_localUsers_onDeleteCascade" + AFTER DELETE ON "localUsers" + FOR EACH ROW BEGIN + DELETE FROM "localUsers" + WHERE "parentID" = "old"."id"; + END + """, + [37]: """ CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_reminders_onDeleteCascade" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN @@ -381,7 +389,7 @@ extension BaseCloudKitTests { WHERE "reminderID" = "old"."id"; END """, - [37]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_tags_onDeleteCascade" AFTER DELETE ON "tags" FOR EACH ROW BEGIN @@ -389,7 +397,7 @@ extension BaseCloudKitTests { WHERE "tagID" = "old"."id"; END """, - [38]: """ + [39]: """ CREATE TRIGGER "sqlitedata_icloud_remindersListPrivates_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -397,7 +405,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [39]: """ + [40]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -405,7 +413,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [40]: """ + [41]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index ad87ba23..77308b85 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,7 @@ import SnapshotTesting import Testing @Suite( - .snapshots(record: .failed), + .snapshots(record: .missing), .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)) ) class BaseCloudKitTests: @unchecked Sendable { diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index eaea28ad..be878d51 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -41,6 +41,11 @@ import SharingGRDB let id: UUID let parentID: Parent.ID } +@Table struct LocalUser: Equatable, Identifiable { + let id: UUID + var name = "" + var parentID: LocalUser.ID? +} @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func database(containerIdentifier: String) throws -> DatabasePool { @@ -133,8 +138,16 @@ func database(containerIdentifier: String) throws -> DatabasePool { ) STRICT """) .execute(db) - - + try #sql( + """ + CREATE TABLE "localUsers" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "name" TEXT NOT NULL DEFAULT '', + "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) } return database } From e9936fe55e25a8b5cf391e4529654e1ab5138aab Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 2 Jul 2025 11:18:02 -0400 Subject: [PATCH 291/581] clean up --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 580104c7..551e4351 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -295,7 +295,7 @@ } } for table in self.tables { - try table.dropTriggers(foreignKeysByTableName: self.foreignKeysByTableName, db: db) + try table.dropTriggers(db: db) } for trigger in SyncMetadata.callbackTriggers.reversed() { try trigger.drop().execute(db) @@ -410,27 +410,10 @@ for trigger in metadataTriggers(parentForeignKey: parentForeignKey) { try trigger.execute(db) } - -// let foreignKeys = foreignKeysByTableName[tableName] ?? [] -// for foreignKey in foreignKeys { -// guard let parent = tablesByName[foreignKey.table] else { -// reportIssue("TODO") -// continue -// } -// try foreignKey.createTriggers(Self.self, belongsTo: parent, db: db) -// } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func dropTriggers( - foreignKeysByTableName: [String: [ForeignKey]], - db: Database - ) throws { -// let foreignKeys = foreignKeysByTableName[tableName] ?? [] -// for foreignKey in foreignKeys.reversed() { -// try foreignKey.dropTriggers(for: Self.self, db: db) -// } - + fileprivate static func dropTriggers(db: Database) throws { for trigger in metadataTriggers(parentForeignKey: nil).reversed() { try trigger.drop().execute(db) } From 7607d84dfa8f6d91a8dbd6ccb343174c9c256e91 Mon Sep 17 00:00:00 2001 From: Sean Woodward Date: Wed, 2 Jul 2025 12:16:13 -0500 Subject: [PATCH 292/581] createContainer explicitly typed --- Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 45dd70d1..7a755f2d 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -411,7 +411,7 @@ final class MockCloudContainer: CloudContainer { fatalError() } - static func createContainer(identifier containerIdentifier: String) -> Self { + static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { @Dependency(\.mockCloudContainers) var mockCloudContainers return mockCloudContainers.withValue { storage in let container = @@ -421,8 +421,7 @@ final class MockCloudContainer: CloudContainer { sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) ) storage[containerIdentifier] = container - // TODO: possible to work around? - return container as! Self + return container } } From 12c2f6b07d20041172d641a0b597b53beec70b45 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 2 Jul 2025 13:26:13 -0400 Subject: [PATCH 293/581] wip --- .../SharingGRDBCore/CloudKit/ForeignKey.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index e3069978..86909f50 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -69,9 +69,9 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteCascade" - AFTER DELETE ON \(quote: parentTableName, delimiter: .identifier) + AFTER DELETE ON \(quote: parentTableName) FOR EACH ROW BEGIN - DELETE FROM \(quote: childTableName, delimiter: .identifier) + DELETE FROM \(quote: childTableName) WHERE \(quote: from) = "old".\(quote: to); END """ @@ -83,10 +83,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteRestrict" - BEFORE DELETE ON \(quote: parentTableName, delimiter: .identifier) + BEFORE DELETE ON \(quote: parentTableName) FOR EACH ROW BEGIN SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM \(quote: childTableName, delimiter: .identifier) + FROM \(quote: childTableName) WHERE \(quote: from) = "old".\(quote: to); END """ @@ -109,9 +109,9 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteSetDefault" - AFTER DELETE ON \(quote: parentTableName, delimiter: .identifier) + AFTER DELETE ON \(quote: parentTableName) FOR EACH ROW BEGIN - UPDATE \(quote: childTableName, delimiter: .identifier) + UPDATE \(quote: childTableName) SET \(quote: from) = \(raw: defaultValue ?? "NULL") WHERE \(quote: from) = "old".\(quote: to); END @@ -124,9 +124,9 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteSetNull" - AFTER DELETE ON \(quote: parentTableName, delimiter: .identifier) + AFTER DELETE ON \(quote: parentTableName) FOR EACH ROW BEGIN - UPDATE \(quote: childTableName, delimiter: .identifier) + UPDATE \(quote: childTableName) SET \(quote: from) = NULL WHERE \(quote: from) = "old".\(quote: to); END @@ -143,9 +143,9 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateCascade" - AFTER UPDATE ON \(quote: parentTableName, delimiter: .identifier) + AFTER UPDATE ON \(quote: parentTableName) FOR EACH ROW BEGIN - UPDATE \(quote: childTableName, delimiter: .identifier) + UPDATE \(quote: childTableName) SET \(quote: from) = "new".\(quote: to) WHERE \(quote: from) = "old".\(quote: to); END @@ -158,10 +158,10 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateRestrict" - BEFORE UPDATE ON \(quote: parentTableName, delimiter: .identifier) + BEFORE UPDATE ON \(quote: parentTableName) FOR EACH ROW BEGIN SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM \(quote: childTableName, delimiter: .identifier) + FROM \(quote: childTableName) WHERE \(quote: from) = "old".\(quote: to); END """ @@ -184,9 +184,9 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateSetDefault" - AFTER UPDATE ON \(quote: parentTableName, delimiter: .identifier) + AFTER UPDATE ON \(quote: parentTableName) FOR EACH ROW BEGIN - UPDATE \(quote: childTableName, delimiter: .identifier) + UPDATE \(quote: childTableName) SET \(quote: from) = \(raw: defaultValue ?? "NULL") WHERE \(quote: from) = "old".\(quote: to); END @@ -199,9 +199,9 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { """ CREATE TEMPORARY TRIGGER IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateSetNull" - AFTER UPDATE ON \(quote: parentTableName, delimiter: .identifier) + AFTER UPDATE ON \(quote: parentTableName) FOR EACH ROW BEGIN - UPDATE \(quote: childTableName, delimiter: .identifier) + UPDATE \(quote: childTableName) SET \(quote: from) = NULL WHERE \(quote: from) = "old".\(quote: to); END From 96e6c4b696bc3f5e81ea0d5339d63f91497ef08a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 2 Jul 2025 14:16:50 -0400 Subject: [PATCH 294/581] fix test --- .../CloudKit/CloudContainer.swift | 1 + .../CloudKitTests/SyncEngineSetUpTests.swift | 36 +++++++++++++++---- .../Internal/BaseCloudKitTests.swift | 1 + .../Internal/CloudKitTestHelpers.swift | 9 ++++- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index 95eaed17..c0d9018a 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -4,6 +4,7 @@ import CloudKit package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { associatedtype Database: CloudDatabase + var containerIdentifier: String? { get } var rawValue: CKContainer { get } var privateCloudDatabase: Database { get } func accept(_ metadata: CKShare.Metadata) async throws -> CKShare diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index 4bf0c18e..8fc52b63 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -28,19 +28,19 @@ extension BaseCloudKitTests { for: RemindersList.recordID(for: UUID(1)) ) personalListRecord.userModificationDate = Date() - personalListRecord.encryptedValues["position"] = 1 + personalListRecord.setValue(1, forKey: "position", at: Date()) let businessListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: UUID(2)) ) businessListRecord.userModificationDate = Date() - businessListRecord.encryptedValues["position"] = 2 + businessListRecord.setValue(2, forKey: "position", at: Date()) let reminderRecord = try syncEngine.private.database.record( for: Reminder.recordID(for: UUID(1)) ) reminderRecord.userModificationDate = Date() - reminderRecord.encryptedValues["position"] = 3 + reminderRecord.setValue(3, forKey: "position", at: Date()) _ = syncEngine.private.database.modifyRecords( saving: [personalListRecord, businessListRecord, reminderRecord], @@ -66,9 +66,33 @@ extension BaseCloudKitTests { .execute(db) } - try await syncEngine.setUpSyncEngine() - let batch = await syncEngine.nextRecordZoneChangeBatch(syncEngine: syncEngine.private) - #expect(batch == nil) + let relaunchedSyncEngine = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: syncEngine.container.containerIdentifier, + privateCloudDatabase: syncEngine.container.privateCloudDatabase as! MockCloudDatabase, + sharedCloudDatabase: syncEngine.container.sharedCloudDatabase as! MockCloudDatabase + ), + privateDatabase: syncEngine.container.privateCloudDatabase as! MockCloudDatabase, + sharedDatabase: syncEngine.container.sharedCloudDatabase as! MockCloudDatabase, + userDatabase: self.userDatabase, + metadatabaseURL: URL + .metadatabase(containerIdentifier: syncEngine.container.containerIdentifier!), + tables: [ + MigratedReminder.self, + MigratedRemindersList.self, + Tag.self, + ReminderTag.self, + Parent.self, + ChildWithOnDeleteRestrict.self, + ChildWithOnDeleteSetNull.self, + ChildWithOnDeleteSetDefault.self, + ], + privateTables: [ + RemindersListPrivate.self + ] + ) + + await relaunchedSyncEngine.processBatch() let remindersLists = try await userDatabase.userRead { db in try MigratedRemindersList.order(by: \.id).fetchAll(db) diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index ad87ba23..5ef9add5 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -36,6 +36,7 @@ class BaseCloudKitTests: @unchecked Sendable { let sharedDatabase = MockCloudDatabase(databaseScope: .shared) _syncEngine = try await SyncEngine( container: MockCloudContainer( + containerIdentifier: testContainerIdentifier, privateCloudDatabase: privateDatabase, sharedCloudDatabase: sharedDatabase ), diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index fc7e1236..386646e8 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -391,10 +391,16 @@ extension MockCloudDatabase: CustomDumpReflectable { } final class MockCloudContainer: CloudContainer { + let containerIdentifier: String? let privateCloudDatabase: MockCloudDatabase let sharedCloudDatabase: MockCloudDatabase - init(privateCloudDatabase: MockCloudDatabase, sharedCloudDatabase: MockCloudDatabase) { + init( + containerIdentifier: String?, + privateCloudDatabase: MockCloudDatabase, + sharedCloudDatabase: MockCloudDatabase + ) { + self.containerIdentifier = containerIdentifier self.privateCloudDatabase = privateCloudDatabase self.sharedCloudDatabase = sharedCloudDatabase } @@ -417,6 +423,7 @@ final class MockCloudContainer: CloudContainer { let container = storage[containerIdentifier] ?? MockCloudContainer( + containerIdentifier: containerIdentifier, privateCloudDatabase: MockCloudDatabase(databaseScope: .private), sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) ) From d122e5b224ed566d44fafc69821b28048d159508 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 2 Jul 2025 14:22:32 -0400 Subject: [PATCH 295/581] wip --- .../Internal/CloudKitTestHelpers.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 386646e8..48f37b28 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -390,7 +390,7 @@ extension MockCloudDatabase: CustomDumpReflectable { } } -final class MockCloudContainer: CloudContainer { +final class MockCloudContainer: CloudContainer, CustomDumpReflectable { let containerIdentifier: String? let privateCloudDatabase: MockCloudDatabase let sharedCloudDatabase: MockCloudDatabase @@ -440,6 +440,16 @@ final class MockCloudContainer: CloudContainer { func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } + var customDumpMirror: Mirror { + Mirror.init( + self, + children: [ + ("privateCloudDatabase", privateCloudDatabase), + ("sharedCloudDatabase", sharedCloudDatabase), + ], + displayStyle: .struct + ) + } } private enum MockCloudContainersKey: TestDependencyKey { From 6552ec0951f5eaccc26c30fa2b46f82269d47ebb Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 2 Jul 2025 11:24:42 -0700 Subject: [PATCH 296/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 34a85c87..3573fafc 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -729,7 +729,6 @@ } } else { upsertFromServerRecord(record) - await refreshLastKnownServerRecord(record) } if let shareReference = record.share, // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in @@ -818,7 +817,6 @@ guard let serverRecord = failedRecordSave.error.serverRecord else { continue } // TODO: do per-field merging here upsertFromServerRecord(serverRecord) - await refreshLastKnownServerRecord(serverRecord) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) case .zoneNotFound: @@ -1013,6 +1011,7 @@ .update { $0.lastKnownServerRecord = record $0._lastKnownServerRecordAllFields = record + $0.userModificationDate = record.userModificationDate } .execute(db) } From 875bd3bcc28ad4bfca8ce6f9dddda292c1c7e61b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 2 Jul 2025 11:31:28 -0700 Subject: [PATCH 297/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 11 ++++------- Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 5866377c..bbaee64e 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -5,17 +5,17 @@ import CustomDump import StructuredQueriesCore extension _CKRecord where Self == CKRecord { - public typealias AllFieldsRepresentation = _AllFieldsRepresentation + typealias AllFieldsRepresentation = _AllFieldsRepresentation public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } extension _CKRecord where Self == CKShare { - public typealias AllFieldsRepresentation = _AllFieldsRepresentation + typealias AllFieldsRepresentation = _AllFieldsRepresentation public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } extension Optional where Wrapped: CKRecord { - public typealias AllFieldsRepresentation = _AllFieldsRepresentation? + typealias AllFieldsRepresentation = _AllFieldsRepresentation? public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? } @@ -93,10 +93,7 @@ public struct _AllFieldsRepresentation: QueryBindable, QueryRe extension CKRecord: _CKRecord {} -public protocol _CKRecord { - associatedtype AllFieldsRepresentation - associatedtype SystemFieldsRepresentation -} +public protocol _CKRecord {} extension CKDatabase.Scope { public struct RawValueRepresentation: QueryBindable, QueryRepresentable { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 5ad3815d..aeaa0a0d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -129,7 +129,7 @@ extension SyncMetadata.TableColumns { SQLQueryExpression("substr(\(parentRecordName), 38)") } - package var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< + var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< SyncMetadata, CKRecord?.AllFieldsRepresentation > { From 44a5c3e85e562a9b16be85588d31c8d85d4bf3a0 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 2 Jul 2025 11:34:49 -0700 Subject: [PATCH 298/581] wip --- Sources/SharingGRDBCore/CloudKit/Metadatabase.swift | 2 +- Sources/SharingGRDBCore/CloudKit/Triggers.swift | 7 ------- .../SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift | 4 ++-- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index d9e02863..94458e18 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -80,7 +80,7 @@ func defaultMetadatabase( } extension QueryFragment { - static func datetime() -> Self { + fileprivate static func datetime() -> Self { Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 8e5d8e31..315ee8b8 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -162,10 +162,3 @@ extension QueryExpression where Self == SQLQueryExpression<()> { private func isUpdatingWithServerRecord() -> SQLQueryExpression { SQLQueryExpression("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") } - -extension QueryExpression { - fileprivate static func datetime>() -> Self - where Self == SQLQueryExpression { - Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") - } -} diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 63e74fb6..e88053a7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -86,7 +86,7 @@ extension BaseCloudKitTests { share: nil ), share: nil, - userModificationDate: nil + userModificationDate: Date(2009-02-13T23:31:30.000Z) ), [1]: SyncMetadata( recordType: "remindersLists", @@ -102,7 +102,7 @@ extension BaseCloudKitTests { share: nil ), share: nil, - userModificationDate: nil + userModificationDate: Date(2009-02-13T23:31:30.000Z) ) ] """ From e1f18113329057f0d4866f25583b893df71b2f3b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 2 Jul 2025 11:55:46 -0700 Subject: [PATCH 299/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../CloudKitTests/CloudKitTests.swift | 4 +- .../CloudKitTests/SharingTests.swift | 37 +++++++++++-------- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index e79f1970..6c68f0d1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -965,7 +965,7 @@ return } guard - let (metadata, allFields) = + let (_, allFields) = try metadatabase.read({ db in try SyncMetadata .find(recordName) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 2fec36cd..bb10ee58 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -383,8 +383,8 @@ extension BaseCloudKitTests { ) let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) - record.encryptedValues["title"] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(60) + record.setValue("Work", forKey: "title", at: serverModificationDate) record.userModificationDate = serverModificationDate _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) @@ -416,7 +416,7 @@ extension BaseCloudKitTests { title: "Work", sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 3ca401be..70f7c672 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -10,6 +10,8 @@ import Testing extension BaseCloudKitTests { @MainActor final class SharingTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.date.now) var now + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareNonRootRecord() async throws { let reminder = Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) @@ -68,10 +70,10 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) ) - remindersListRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() - remindersListRecord.encryptedValues["isCompleted"] = false - remindersListRecord.encryptedValues["title"] = "Personal" - remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1_234_567_890) + remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + remindersListRecord.userModificationDate = now await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) @@ -111,7 +113,10 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), @@ -135,18 +140,18 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) ) - remindersListRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() - remindersListRecord.encryptedValues["title"] = "Personal" - remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1_234_567_890) + remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + remindersListRecord.userModificationDate = now let reminderRecord = CKRecord( recordType: Reminder.tableName, recordID: Reminder.recordID(for: UUID(1), zoneID: externalZoneID) ) - reminderRecord.encryptedValues["id"] = UUID(1).uuidString.lowercased() - reminderRecord.encryptedValues["isCompleted"] = false - reminderRecord.encryptedValues["title"] = "Get milk" - reminderRecord.encryptedValues["remindersListID"] = UUID(1).uuidString.lowercased() - remindersListRecord.userModificationDate = Date(timeIntervalSince1970: 1_234_567_890) + reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + reminderRecord.setValue(false, forKey: "isCompleted", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "remindersListID", at: now) + remindersListRecord.userModificationDate = now await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) @@ -155,7 +160,7 @@ extension BaseCloudKitTests { } await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: syncEngine.container, as: .customDump, record: .all) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -168,7 +173,9 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] ), From b3179637fd6dec9b1a5598679a88cef9c215d9d2 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 2 Jul 2025 13:55:11 -0700 Subject: [PATCH 300/581] wip --- .../CloudKitTests/CloudKitTests.swift | 155 +++++++++++++++++- 1 file changed, 153 insertions(+), 2 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index bb10ee58..b796dfac 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -660,7 +660,7 @@ extension BaseCloudKitTests { } } - @Test func merge() async throws { + @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "") @@ -811,7 +811,158 @@ extension BaseCloudKitTests { """ } } - } + + @Test func serverRecordUpdatedBeforeClientRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "") + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let userModificationDate = now.addingTimeInterval(30) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + record.userModificationDate = userModificationDate + _ = syncEngine.private.database.modifyRecords(saving: [record]) + + try await withDependencies { + $0.date.now = now.addingTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + } + } + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Buy milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:00.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:00.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 1, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Buy milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:00.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } // TODO: Test what happens when we delete locally and then an edit comes in from the server } From e58e5390d68d31fa99cc127d09a7cdd024ed67b7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 2 Jul 2025 14:20:52 -0700 Subject: [PATCH 301/581] wip --- Examples/Reminders/Reminders.entitlements | 2 +- Examples/Reminders/RemindersApp.swift | 4 +++- Examples/Reminders/Schema.swift | 4 +++- .../CloudKit/CloudKit+StructuredQueries.swift | 20 +++++++------------ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 1 - 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/Examples/Reminders/Reminders.entitlements b/Examples/Reminders/Reminders.entitlements index a416e7fa..21e4bff4 100644 --- a/Examples/Reminders/Reminders.entitlements +++ b/Examples/Reminders/Reminders.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.co.pointfree.SQLiteData.demos.Reminders + iCloud.co.pointfree.SQLiteData.demos.field-timestamps-2.Reminders com.apple.developer.icloud-services diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 07cf7081..a1c89133 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -13,7 +13,9 @@ struct RemindersApp: App { try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer(identifier: "iCloud.co.pointfree.SQLiteData.demos.Reminders"), + container: CKContainer( + identifier: "iCloud.co.pointfree.SQLiteData.demos.field-timestamps-2.Reminders" + ), database: $0.defaultDatabase, tables: [ RemindersList.self, diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 6da798b0..2935d4f6 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -105,7 +105,9 @@ func appDatabase() throws -> any DatabaseWriter { var configuration = Configuration() configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in - try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.Reminders") + try db.attachMetadatabase( + containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.field-timestamps-2.Reminders" + ) #if DEBUG db.trace(options: .profile) { if context == .live { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index bbaee64e..e9b23e07 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -151,8 +151,7 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - // TODO: This should be 'encryptedValues[' - guard self[at: key] < userModificationDate else { return false } + guard encryptedValues[at: key] < userModificationDate else { return false } let hash = SHA256.hash(data: newValue).compactMap { String(format: "%02hhx", $0) }.joined() let blobURL = URL.temporaryDirectory.appendingPathComponent(hash) let asset = CKAsset(fileURL: blobURL) @@ -161,8 +160,7 @@ extension CKRecord { try Data(newValue).write(to: blobURL) } self[key] = asset - // TODO: This should be 'encryptedValues[' - self[at: key] = userModificationDate + encryptedValues[at: key] = userModificationDate return true } return false @@ -175,12 +173,10 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - // TODO: This should be 'encryptedValues[' - guard self[at: key] < userModificationDate else { return false } + guard encryptedValues[at: key] < userModificationDate else { return false } if (self[key] as? CKAsset)?.fileURL != newValue.fileURL { self[key] = newValue - // TODO: This should be 'encryptedValues[' - self[at: key] = userModificationDate + encryptedValues[at: key] = userModificationDate return true } return false @@ -191,8 +187,7 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - // TODO: 'self[at: key]' should always be 'encryptedValues[at: key]' - guard Swift.max(encryptedValues[at: key], self[at: key]) < userModificationDate + guard Swift.max(encryptedValues[at: key], encryptedValues[at: key]) < userModificationDate else { return false } if encryptedValues[key] != nil { encryptedValues[key] = nil @@ -200,7 +195,7 @@ extension CKRecord { return true } else if self[key] != nil { self[key] = nil - self[at: key] = userModificationDate + encryptedValues[at: key] = userModificationDate return true } return false @@ -251,8 +246,7 @@ extension CKRecord { self.userModificationDate = other.userModificationDate for key in other.versionedKeys() { let didSet = if let value = other[key] as? CKAsset { - // TODO: This should be 'other.encryptedValues[' - setValue(value, forKey: key, at: other[at: key]) + setValue(value, forKey: key, at: other.encryptedValues[at: key]) } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { setValue(value, forKey: key, at: other.encryptedValues[at: key]) } else if other.encryptedValues[key] == nil { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6c68f0d1..46d9ec6f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -867,7 +867,6 @@ switch failedRecordSave.error.code { case .serverRecordChanged: guard let serverRecord = failedRecordSave.error.serverRecord else { continue } - // TODO: do per-field merging here upsertFromServerRecord(serverRecord) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) From 2613acacd1f288dfe1d1f6c78e485ff322e078d3 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 2 Jul 2025 14:40:26 -0700 Subject: [PATCH 302/581] wip --- Examples/Reminders/RemindersLists.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 17 +++++++---------- .../SharingGRDBCore/CloudKit/SyncMetadata.swift | 2 +- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index ef251396..18b416d2 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -156,7 +156,7 @@ class RemindersListsModel { var id: RemindersList.ID { remindersList.id } var remindersCount: Int var remindersList: RemindersList - @Column(as: CKShare?.ShareDataRepresentation.self) + @Column(as: CKShare?.SystemFieldsRepresentation.self) var share: CKShare? } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 46d9ec6f..6222ea8d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -963,16 +963,13 @@ else { return } - guard - let (_, allFields) = - try metadatabase.read({ db in - try SyncMetadata - .find(recordName) - .select { ($0, $0._lastKnownServerRecordAllFields) } - .fetchOne(db) - }) - ?? nil - else { return } + let allFields = try metadatabase.read { db in + try SyncMetadata + .find(recordName) + .select(\._lastKnownServerRecordAllFields) + .fetchOne(db) + } + ?? nil func open>(_: T.Type) throws { var columnNames = T.TableColumns.allColumns.map(\.name) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index aeaa0a0d..001d82bd 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -44,7 +44,7 @@ public struct SyncMetadata: Hashable, Sendable { // TODO: _lastKnownAllFields /// The `CKShare` associated with this record, if it is shared. - // @Column(as: CKShare?.ShareDataRepresentation.self) + // @Column(as: CKShare?.SystemFieldsRepresentation.self) public var share: CKShare? /// The date the user last modified the record. From b7d4f5ea89403fe1788cc886ece47039e831910e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 2 Jul 2025 14:46:07 -0700 Subject: [PATCH 303/581] wip --- .../CloudKitTests/SharingTests.swift | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 70f7c672..2d3dd972 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -75,11 +75,15 @@ extension BaseCloudKitTests { remindersListRecord.setValue("Personal", forKey: "title", at: now) remindersListRecord.userModificationDate = now - await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) - - try await userDatabase.userWrite { db in - try db.seed { - Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]) + + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + } } } @@ -89,21 +93,25 @@ extension BaseCloudKitTests { MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), share: nil, id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Get milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), @@ -119,10 +127,6 @@ extension BaseCloudKitTests { sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) ) """ @@ -153,18 +157,26 @@ extension BaseCloudKitTests { reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "remindersListID", at: now) remindersListRecord.userModificationDate = now - await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]) - try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).delete().execute(db) + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).delete().execute(db) + } } await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump, record: .all) { + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, storage: [ [0]: CKRecord( recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), @@ -178,10 +190,6 @@ extension BaseCloudKitTests { sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) ) ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) ) """ From b4b7709192f1f3a1d1263754bab6de0fb1973b6c Mon Sep 17 00:00:00 2001 From: Sean Woodward Date: Wed, 2 Jul 2025 19:12:39 -0500 Subject: [PATCH 304/581] userWrite to userRead for closures without updates (except queries to temporary tables) --- .../CloudKitTests/CloudKitTests.swift | 16 ++++++++-------- .../CloudKitTests/RecordTypeTests.swift | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 0f66a71c..87105e5f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -12,7 +12,7 @@ extension BaseCloudKitTests { final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() throws { - let zones = try userDatabase.userWrite { db in + let zones = try userDatabase.userRead { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: zones, as: .customDump) { @@ -142,12 +142,12 @@ extension BaseCloudKitTests { """ } - try await userDatabase.userWrite { db in + try await userDatabase.userRead { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 1) } try await syncEngine.tearDownSyncEngine() - try await self.userDatabase.userWrite { db in + try await self.userDatabase.userRead { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 0) } @@ -207,7 +207,7 @@ extension BaseCloudKitTests { as: String.self ) assertInlineSnapshot( - of: try { try userDatabase.userWrite { try query.fetchAll($0) } }(), + of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), as: .customDump ) { """ @@ -222,7 +222,7 @@ extension BaseCloudKitTests { try await syncEngine.tearDownSyncEngine() assertInlineSnapshot( - of: try { try userDatabase.userWrite { try query.fetchAll($0) } }(), + of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), as: .customDump ) { """ @@ -445,7 +445,7 @@ extension BaseCloudKitTests { } let userModificationDate = try #require( - try await userDatabase.userWrite { db in + try await userDatabase.userRead { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .select(\.userModificationDate) @@ -467,7 +467,7 @@ extension BaseCloudKitTests { ) let metadata = try #require( - try await userDatabase.userWrite { db in + try await userDatabase.userRead { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) @@ -540,7 +540,7 @@ extension BaseCloudKitTests { try await userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchAll(db) } == [] ) - let metadata = try await userDatabase.userWrite { db in + let metadata = try await userDatabase.userRead { db in try SyncMetadata .find(RemindersList.recordName(for: UUID(1))) .fetchOne(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 69707f0b..98839e74 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -11,7 +11,7 @@ extension BaseCloudKitTests { 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.userWrite { db in + let recordTypes = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: recordTypes, as: .customDump) { @@ -110,25 +110,25 @@ extension BaseCloudKitTests { @Test func tearDown() async throws { try await syncEngine.tearDownSyncEngine() - try await userDatabase.userWrite { db in + try await userDatabase.userRead { db in try #expect(RecordType.all.fetchAll(db) == []) } } @Test func resetUp() async throws { - let recordTypes = try await userDatabase.userWrite { db in + let recordTypes = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) } try await syncEngine.tearDownSyncEngine() try await syncEngine.setUpSyncEngine() - let recordTypesAfterReSetup = try await userDatabase.userWrite { db in + let recordTypesAfterReSetup = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) } expectNoDifference(recordTypes, recordTypesAfterReSetup) } @Test func migration() async throws { - let recordTypes = try await userDatabase.userWrite { db in + let recordTypes = try await userDatabase.userRead { db in try RecordType.order(by: \.tableName).fetchAll(db) } try await syncEngine.tearDownSyncEngine() @@ -142,7 +142,7 @@ extension BaseCloudKitTests { } try await syncEngine.setUpSyncEngine() - let recordTypesAfterMigration = try await userDatabase.userWrite { db in + let recordTypesAfterMigration = try await userDatabase.userRead { db in try RecordType.order(by: \.tableName).fetchAll(db) } let remindersTableIndex = try #require( From d9ec28c48ce6895602cd55ee85676d06846e89ae Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 3 Jul 2025 08:02:36 -0400 Subject: [PATCH 305/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index e9b23e07..69269fa4 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -46,8 +46,7 @@ public struct _SystemFieldsRepresentation: QueryBindable, Quer } if isTesting { queryOutput._recordChangeTag = coder - .decodeObject(of: NSString.self, forKey: "_recordChangeTag") - as? String + .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String } self.init(queryOutput: queryOutput) } @@ -82,8 +81,7 @@ public struct _AllFieldsRepresentation: QueryBindable, QueryRe } if isTesting { queryOutput._recordChangeTag = coder - .decodeObject(of: NSString.self, forKey: "_recordChangeTag") - as? String + .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String } self.init(queryOutput: queryOutput) } @@ -136,13 +134,13 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - guard encryptedValues[at: key] < userModificationDate else { return false } - if encryptedValues[key] != newValue { - encryptedValues[key] = newValue - encryptedValues[at: key] = userModificationDate - return true - } - return false + guard + encryptedValues[at: key] < userModificationDate, + encryptedValues[key] != newValue + else { return false } + encryptedValues[key] = newValue + encryptedValues[at: key] = userModificationDate + return true } @discardableResult @@ -173,13 +171,13 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - guard encryptedValues[at: key] < userModificationDate else { return false } - if (self[key] as? CKAsset)?.fileURL != newValue.fileURL { - self[key] = newValue - encryptedValues[at: key] = userModificationDate - return true - } - return false + guard + encryptedValues[at: key] < userModificationDate, + (self[key] as? CKAsset)?.fileURL != newValue.fileURL + else { return false } + self[key] = newValue + encryptedValues[at: key] = userModificationDate + return true } @discardableResult From 37bcc37c51b613df13aff27fcfc731daece1698b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 3 Jul 2025 08:39:49 -0400 Subject: [PATCH 306/581] clean up and print timestamps in tests only for merge conflict tests --- .../CloudKit/CloudKit+StructuredQueries.swift | 9 +- .../CloudKit/SyncMetadata.swift | 14 +- .../CloudKitTests/CloudKitTests.swift | 368 +----------------- .../CloudKitTests/ForeignKeyTests.swift | 112 +----- .../CloudKitTests/MergeConflictTests.swift | 320 +++++++++++++++ .../CloudKitTests/MetadataTests.swift | 57 +-- .../CloudKitTests/NewTableSyncTests.swift | 12 +- .../NextRecordZoneChangeBatchTests.swift | 28 +- .../CloudKitTests/SharingTests.swift | 18 +- .../Internal/CloudKit+CustomDump.swift | 10 +- .../Internal/CloudKitTestHelpers.swift | 23 ++ 11 files changed, 424 insertions(+), 547 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 69269fa4..59e307a5 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -234,8 +234,11 @@ extension CKRecord { func versionedKeys() -> [FieldKey] { allKeys() - .filter { $0.hasPrefix("\(Self.userModificationDateKey)_") } - .map { String($0.dropFirst("\(Self.userModificationDateKey)_".count)) } + .compactMap { + $0.hasPrefix("\(Self.userModificationDateKey)_") + ? String($0.dropFirst("\(Self.userModificationDateKey)_".count)) + : nil + } } package func update(with other: CKRecord, columnNames: inout [String]) { @@ -265,7 +268,7 @@ extension CKRecord { } } - fileprivate static let userModificationDateKey = + package static let userModificationDateKey = "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 001d82bd..ceb30cc5 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -50,10 +50,6 @@ public struct SyncMetadata: Hashable, Sendable { /// The date the user last modified the record. public var userModificationDate: Date - var _lastKnownServerRecordAllFields: CKRecord? { - fatalError() - } - package init( recordType: String, recordName: RecordName, @@ -140,6 +136,16 @@ extension SyncMetadata.TableColumns { } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata { + fileprivate var _lastKnownServerRecordAllFields: CKRecord? { + fatalError(""" + Never invoke this directly. Use 'SyncMetadata.TableColumns._lastKnownServerRecordAllFields' \ + instead. + """) + } +} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTable { /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index b796dfac..e0d28f36 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -131,10 +131,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -180,10 +177,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -196,9 +190,9 @@ extension BaseCloudKitTests { } let metadata = - try await userDatabase.userRead { db in - try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) - } + try await userDatabase.userRead { db in + try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) + } #expect(metadata != nil) } @@ -262,10 +256,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -300,10 +291,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Work", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + title: "Work" ) ] ), @@ -358,10 +346,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -413,10 +398,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Work", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + title: "Work" ) ] ), @@ -449,10 +431,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -506,10 +485,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -542,10 +518,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -562,7 +535,7 @@ extension BaseCloudKitTests { #expect( try await userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchAll(db) } - == [] + == [] ) let metadata = try await userDatabase.userWrite { db in try SyncMetadata @@ -632,10 +605,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "" ), [1]: CKRecord( recordID: CKRecord.ID(2:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -643,10 +613,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000002", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "" ) ] ), @@ -659,310 +626,7 @@ extension BaseCloudKitTests { } } } - - @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: UUID(1), title: "") - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) - } - } - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "00000000-0000-0000-0000-000000000001", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) - let userModificationDate = now.addingTimeInterval(60) - record.setValue("Buy milk", forKey: "title", at: userModificationDate) - record.userModificationDate = userModificationDate - _ = syncEngine.private.database.modifyRecords(saving: [record]) - - try await withDependencies { - $0.date.now = now.addingTimeInterval(30) - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) - } - } - await syncEngine.processBatch() - - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Buy milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "00000000-0000-0000-0000-000000000001", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - await syncEngine.processBatch() - - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 1, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Buy milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:00.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "00000000-0000-0000-0000-000000000001", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @Test func serverRecordUpdatedBeforeClientRecord() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: UUID(1), title: "") - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) - } - } - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "00000000-0000-0000-0000-000000000001", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) - let userModificationDate = now.addingTimeInterval(30) - record.setValue("Buy milk", forKey: "title", at: userModificationDate) - record.userModificationDate = userModificationDate - _ = syncEngine.private.database.modifyRecords(saving: [record]) - - try await withDependencies { - $0.date.now = now.addingTimeInterval(60) - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) - } - } - await syncEngine.processBatch() - - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Buy milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:00.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:00.000Z) - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "00000000-0000-0000-0000-000000000001", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - await syncEngine.processBatch() - - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 1, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Buy milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:00.000Z) - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "00000000-0000-0000-0000-000000000001", - title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } } + } // TODO: Test what happens when we delete locally and then an edit comes in from the server } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 19ae9b98..d1df23b8 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -35,12 +35,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Groceries" ), [1]: CKRecord( recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -50,12 +45,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000002", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Walk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Walk" ), [2]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -63,10 +53,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -128,19 +115,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) + parentID: "00000000-0000-0000-0000-000000000001" ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) + id: "00000000-0000-0000-0000-000000000001" ) ] ), @@ -180,10 +162,7 @@ extension BaseCloudKitTests { recordType: "childWithOnDeleteSetNulls", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:32:30.000Z) + id: "00000000-0000-0000-0000-000000000001" ) ] ), @@ -221,12 +200,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000002", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Groceries" ), [1]: CKRecord( recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -236,12 +210,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000003", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Walk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Walk" ), [2]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -249,10 +218,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -297,12 +263,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000002", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000009", - title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Groceries" ), [1]: CKRecord( recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -312,12 +273,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000003", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000009", - title: "Walk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Walk" ), [2]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -325,10 +281,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ), [3]: CKRecord( recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -336,10 +289,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000009", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + title: "Personal" ) ] ), @@ -374,19 +324,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) + parentID: "00000000-0000-0000-0000-000000000001" ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) + id: "00000000-0000-0000-0000-000000000001" ) ] ), @@ -429,19 +374,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) + parentID: "00000000-0000-0000-0000-000000000001" ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) + id: "00000000-0000-0000-0000-000000000001" ) ] ), @@ -476,19 +416,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) + parentID: "00000000-0000-0000-0000-000000000001" ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) + id: "00000000-0000-0000-0000-000000000001" ) ] ), @@ -531,19 +466,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_parentID: Date(2009-02-13T23:31:30.000Z) + parentID: "00000000-0000-0000-0000-000000000001" ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z) + id: "00000000-0000-0000-0000-000000000001" ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift new file mode 100644 index 00000000..947e7e7f --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -0,0 +1,320 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import OrderedCollections +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + @Suite(.printTimestamps) + final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.date.now) var now + + @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "") + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let userModificationDate = now.addingTimeInterval(60) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + record.userModificationDate = userModificationDate + _ = syncEngine.private.database.modifyRecords(saving: [record]) + + try await withDependencies { + $0.date.now = now.addingTimeInterval(30) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + } + } + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Buy milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 1, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Buy milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:00.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test func serverRecordUpdatedBeforeClientRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "") + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let userModificationDate = now.addingTimeInterval(30) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + record.userModificationDate = userModificationDate + _ = syncEngine.private.database.modifyRecords(saving: [record]) + + try await withDependencies { + $0.date.now = now.addingTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + } + } + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Buy milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:00.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:00.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 1, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Buy milk", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:30.000Z), + sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:00.000Z) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "", + sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), + sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index ac5f1657..5a6eb7ca 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -35,12 +35,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Groceries" ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -48,10 +43,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ), [2]: CKRecord( recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -59,10 +51,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000002", - title: "Work", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Work" ) ] ), @@ -114,12 +103,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000002", - title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Groceries" ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -127,10 +111,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ), [2]: CKRecord( recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -138,10 +119,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000002", - title: "Work", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Work" ) ] ), @@ -178,11 +156,7 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", reminderID: "00000000-0000-0000-0000-000000000001", - tagID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_reminderID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_tagID: Date(2009-02-13T23:31:30.000Z) + tagID: "00000000-0000-0000-0000-000000000001" ), [1]: CKRecord( recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -192,12 +166,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Groceries", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Groceries" ), [2]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -205,10 +174,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ), [3]: CKRecord( recordID: CKRecord.ID(1:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -216,10 +182,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "weekend", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "weekend" ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index e88053a7..3f3cb479 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -35,12 +35,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Write blog post", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Write blog post" ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -48,10 +43,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 0b9bf860..1022a49e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -109,10 +109,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -149,12 +146,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Get milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Get milk" ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -162,10 +154,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), @@ -200,11 +189,7 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", position: 42, - remindersListID: "00000000-0000-0000-0000-000000000001", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_position: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z) + remindersListID: "00000000-0000-0000-0000-000000000001" ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -212,10 +197,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 2d3dd972..b4376e29 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -106,12 +106,7 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Get milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + title: "Get milk" ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), @@ -120,11 +115,7 @@ extension BaseCloudKitTests { share: nil, id: "00000000-0000-0000-0000-000000000001", isCompleted: 0, - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ) @@ -184,10 +175,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - title: "Personal", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title: "Personal" ) ] ) diff --git a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift index 962f2495..6c1c4c20 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift @@ -20,14 +20,20 @@ @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension CKRecord: @retroactive CustomDumpReflectable { + @TaskLocal static var printTimestamps = false + public var customDumpMirror: Mirror { let keys = encryptedValues.allKeys() + .filter { key in + CKRecord.printTimestamps + || !key.hasPrefix(CKRecord.userModificationDateKey) + } .sorted { lhs, rhs in ( - lhs.hasPrefix("\(String.sqliteDataCloudKitSchemaName)_userModificationDate") ? 1 : 0, + lhs.hasPrefix(CKRecord.userModificationDateKey) ? 1 : 0, lhs ) < ( - rhs.hasPrefix("\(String.sqliteDataCloudKitSchemaName)_userModificationDate") ? 1 : 0, + rhs.hasPrefix(CKRecord.userModificationDateKey) ? 1 : 0, rhs ) } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 1a26659f..1aad478c 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -640,3 +640,26 @@ extension SyncEngine { } } } + +struct _PrintTimestampsScope: SuiteTrait, TestScoping, TestTrait { + let printTimestamps: Bool + init(_ printTimestamps: Bool = true) { + self.printTimestamps = printTimestamps + } + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await CKRecord.$printTimestamps.withValue(true) { + try await function() + } + } +} + +extension Trait where Self == _PrintTimestampsScope { + static var printTimestamps: Self { .init() } + static func printTimestamps(_ printTimestamps: Bool) -> Self { + .init(printTimestamps) + } +} From f39cd355f8dbaf7d70291f6a57d18da257a23329 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 3 Jul 2025 12:10:06 -0400 Subject: [PATCH 307/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 142 +++++++++++--- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 21 ++- .../CloudKitTests/MergeConflictTests.swift | 178 +++++++++++++----- .../CloudKitTests/NewTableSyncTests.swift | 4 +- .../CloudKitTests/SyncEngineSetUpTests.swift | 14 +- .../Internal/BaseCloudKitTests.swift | 2 +- .../Internal/CloudKit+CustomDump.swift | 30 ++- .../Internal/CloudKitTestHelpers.swift | 42 +++-- 8 files changed, 316 insertions(+), 117 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 59e307a5..b6442a1a 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -134,9 +134,11 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { + print("!!!") guard - encryptedValues[at: key] < userModificationDate, - encryptedValues[key] != newValue + encryptedValues[at: key] < userModificationDate +// , +// encryptedValues[key] != newValue else { return false } encryptedValues[key] = newValue encryptedValues[at: key] = userModificationDate @@ -149,19 +151,19 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { + print("!!!") guard encryptedValues[at: key] < userModificationDate else { return false } let hash = SHA256.hash(data: newValue).compactMap { String(format: "%02hhx", $0) }.joined() let blobURL = URL.temporaryDirectory.appendingPathComponent(hash) let asset = CKAsset(fileURL: blobURL) - if (self[key] as? CKAsset)?.fileURL != blobURL { - withErrorReporting { - try Data(newValue).write(to: blobURL) - } - self[key] = asset - encryptedValues[at: key] = userModificationDate - return true + guard (self[key] as? CKAsset)?.fileURL != blobURL + else { return false } + withErrorReporting { + try Data(newValue).write(to: blobURL) } - return false + self[key] = asset + encryptedValues[at: key] = userModificationDate + return true } @discardableResult @@ -171,6 +173,7 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { + print("!!!") guard encryptedValues[at: key] < userModificationDate, (self[key] as? CKAsset)?.fileURL != newValue.fileURL @@ -185,6 +188,7 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { + print("!!!") guard Swift.max(encryptedValues[at: key], encryptedValues[at: key]) < userModificationDate else { return false } if encryptedValues[key] != nil { @@ -241,24 +245,105 @@ extension CKRecord { } } - package func update(with other: CKRecord, columnNames: inout [String]) { + package func update2>( + with other: CKRecord, + row: T, + columnNames: inout [String] + ) { typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable self.userModificationDate = other.userModificationDate - for key in other.versionedKeys() { - let didSet = if let value = other[key] as? CKAsset { - setValue(value, forKey: key, at: other.encryptedValues[at: key]) - } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { - setValue(value, forKey: key, at: other.encryptedValues[at: key]) - } else if other.encryptedValues[key] == nil { - removeValue(forKey: key, at: other.encryptedValues[at: key]) - } else { - false - } - if didSet { - columnNames.removeAll(where: { $0 == key }) + + for column in T.TableColumns.allColumns { + func open(_ column: some TableColumnExpression) { + let key = column.name + let column = column as! any TableColumnExpression + let localValue = Value(queryOutput: row[keyPath: column.keyPath]) + let lastKnownValue = other.encryptedValues[key] as? Value + + print(key, localValue, lastKnownValue) + print("!!!") + if key == "title" || key == "isCompleted" { + print("!!!!") + } + var didSet = if let value = other[key] as? CKAsset { + setValue(value, forKey: key, at: other.encryptedValues[at: key]) + } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { + setValue(value, forKey: key, at: other.encryptedValues[at: key]) + } else if other.encryptedValues[key] == nil { + removeValue(forKey: key, at: other.encryptedValues[at: key]) + } else { + false + } + if !didSet && (SharingGRDBCore.isEqual(localValue, lastKnownValue) == false) { + switch localValue.queryBinding { + case .blob(_): + fatalError() + case .double(let value): + encryptedValues[key] = value + case .date(let value): + encryptedValues[key] = value + case .int(let value): + encryptedValues[key] = value + case .null: + encryptedValues[key] = nil + case .text(let value): + encryptedValues[key] = value + case .uuid(let value): + encryptedValues[key] = value.uuidString.lowercased() + case .invalid: + fatalError() + } + } + if didSet { + columnNames.removeAll(where: { $0 == key }) + } + +// switch localValue.queryBinding { +// case .blob(let value): +// setValue(value, forKey: column.name, at: userModificationDate) +// +// case .double(let value): +// setValue(value, forKey: column.name, at: userModificationDate) +// case .date(let value): +// setValue(value, forKey: column.name, at: userModificationDate) +// case .int(let value): +// setValue(value, forKey: column.name, at: userModificationDate) +// case .null: +// removeValue(forKey: column.name, at: userModificationDate) +// case .text(let value): +// setValue(value, forKey: column.name, at: userModificationDate) +// case .uuid(let value): +// setValue( +// value.uuidString.lowercased(), +// forKey: column.name, +// at: userModificationDate +// ) +// case .invalid(let error): +// reportIssue(error) +// false +// } } + open(column) } + +// for key in other.versionedKeys() { +// if key == "title" || key == "isCompleted" { +// print("!!!!") +// } +// var didSet = if let value = other[key] as? CKAsset { +// setValue(value, forKey: key, at: other.encryptedValues[at: key]) +// } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { +// setValue(value, forKey: key, at: other.encryptedValues[at: key]) +// } else if other.encryptedValues[key] == nil { +// removeValue(forKey: key, at: other.encryptedValues[at: key]) +// } else { +// false +// } +// if didSet { +// columnNames.removeAll(where: { $0 == key }) +// } +// } } package var userModificationDate: Date { @@ -299,3 +384,14 @@ extension CKRecord { } } #endif + + +private func isEqual(_ lhs: T, _ rhs: T) -> Bool? { + guard let lhs = lhs as? any Equatable + else { return nil } + + func open(_ lhs: S) -> Bool? { + (rhs as? S).map { lhs == $0 } + } + return open(lhs) +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6222ea8d..9276e7be 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -793,6 +793,7 @@ } } + // TODO: Group by recordType and delete in batches for (recordID, recordType) in deletions { if let table = tablesByName[recordType] { guard let recordName = SyncMetadata.RecordName(recordID: recordID) @@ -960,9 +961,7 @@ return } guard let recordName = SyncMetadata.RecordName(recordID: serverRecord.recordID) - else { - return - } + else { return } let allFields = try metadatabase.read { db in try SyncMetadata .find(recordName) @@ -975,7 +974,15 @@ var columnNames = T.TableColumns.allColumns.map(\.name) if let allFields { - serverRecord.update(with: allFields, columnNames: &columnNames) + let row = try userDatabase.read { db in + try T.find(recordName.id).fetchOne(db) + } + guard let row + else { + fatalError() + return + } + serverRecord.update2(with: allFields, row: T(queryOutput: row), columnNames: &columnNames) } var query: QueryFragment = "INSERT INTO \(T.self) (" @@ -1008,13 +1015,9 @@ ) // TODO: Append more ON CONFLICT clauses for each unique constraint? // TODO: Use WHERE to scope the update? - guard let recordName = SyncMetadata.RecordName(recordID: serverRecord.recordID) - else { - reportIssue("???") - return - } try userDatabase.write { db in try SQLQueryExpression(query).execute(db) + // TODO: Do we need to update parentRecordName too in case it changed? try SyncMetadata .insert { ( diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 947e7e7f..43291bb3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -9,8 +9,7 @@ import Testing extension BaseCloudKitTests { @MainActor - @Suite(.printTimestamps) - final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { + @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { @Dependency(\.date.now) var now @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { @@ -33,14 +32,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, isCompleted: 0, + isCompleted🗓️: 0, remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title🗓️: 0, + 🗓️: 0 ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -48,10 +47,10 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title🗓️: 0, + 🗓️: 0 ) ] ), @@ -67,7 +66,7 @@ extension BaseCloudKitTests { let userModificationDate = now.addingTimeInterval(60) record.setValue("Buy milk", forKey: "title", at: userModificationDate) record.userModificationDate = userModificationDate - _ = syncEngine.private.database.modifyRecords(saving: [record]) + let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() try await withDependencies { $0.date.now = now.addingTimeInterval(30) @@ -90,14 +89,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, isCompleted: 0, + isCompleted🗓️: 0, remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, title: "Buy milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + title🗓️: 60, + 🗓️: 60 ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -105,10 +104,10 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title🗓️: 0, + 🗓️: 0 ) ] ), @@ -121,6 +120,7 @@ extension BaseCloudKitTests { } await syncEngine.processBatch() + await modificationCallback() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ @@ -134,14 +134,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, isCompleted: 1, + isCompleted🗓️: 30, remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, title: "Buy milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:00.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:30.000Z) + title🗓️: 60, + 🗓️: 60 ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -149,10 +149,10 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title🗓️: 0, + 🗓️: 0 ) ] ), @@ -185,14 +185,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, isCompleted: 0, + isCompleted🗓️: 0, remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title🗓️: 0, + 🗓️: 0 ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -200,10 +200,10 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title🗓️: 0, + 🗓️: 0 ) ] ), @@ -219,7 +219,7 @@ extension BaseCloudKitTests { let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) record.userModificationDate = userModificationDate - _ = syncEngine.private.database.modifyRecords(saving: [record]) + let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() try await withDependencies { $0.date.now = now.addingTimeInterval(60) @@ -242,14 +242,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, isCompleted: 0, + isCompleted🗓️: 0, remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, title: "Buy milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:00.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:00.000Z) + title🗓️: 30, + 🗓️: 30 ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -257,10 +257,10 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title🗓️: 0, + 🗓️: 0 ) ] ), @@ -273,6 +273,7 @@ extension BaseCloudKitTests { } await syncEngine.processBatch() + await modificationCallback() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ @@ -286,14 +287,14 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, isCompleted: 1, + isCompleted🗓️: 60, remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, title: "Buy milk", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_isCompleted: Date(2009-02-13T23:32:30.000Z), - sqlitedata_icloud_userModificationDate_remindersListID: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:32:00.000Z) + title🗓️: 30, + 🗓️: 60 ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -301,10 +302,10 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, title: "", - sqlitedata_icloud_userModificationDate: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_id: Date(2009-02-13T23:31:30.000Z), - sqlitedata_icloud_userModificationDate_title: Date(2009-02-13T23:31:30.000Z) + title🗓️: 0, + 🗓️: 0 ) ] ), @@ -316,5 +317,78 @@ extension BaseCloudKitTests { """ } } + + @Test func foo() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "") + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let userModificationDate = now.addingTimeInterval(30) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + record.userModificationDate = userModificationDate + let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() + + try await withDependencies { + $0.date.now = now.addingTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + } + } + await modificationCallback() + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 1, + isCompleted🗓️: 60, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 30, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + +// await syncEngine.processBatch() +// await modificationCallback() +// +// assertInlineSnapshot(of: syncEngine.container, as: .customDump) + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 3f3cb479..5214f769 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -78,7 +78,7 @@ extension BaseCloudKitTests { share: nil ), share: nil, - userModificationDate: Date(2009-02-13T23:31:30.000Z) + userModificationDate: Date(1970-01-01T00:00:00.000Z) ), [1]: SyncMetadata( recordType: "remindersLists", @@ -94,7 +94,7 @@ extension BaseCloudKitTests { share: nil ), share: nil, - userModificationDate: Date(2009-02-13T23:31:30.000Z) + userModificationDate: Date(1970-01-01T00:00:00.000Z) ) ] """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index 8fc52b63..c336d049 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -42,11 +42,9 @@ extension BaseCloudKitTests { reminderRecord.userModificationDate = Date() reminderRecord.setValue(3, forKey: "position", at: Date()) - _ = syncEngine.private.database.modifyRecords( - saving: [personalListRecord, businessListRecord, reminderRecord], - deleting: [], - savePolicy: .ifServerRecordUnchanged, - atomically: true + _ = await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord, businessListRecord, reminderRecord] ) try await userDatabase.userWrite { db in @@ -67,11 +65,7 @@ extension BaseCloudKitTests { } let relaunchedSyncEngine = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: syncEngine.container.containerIdentifier, - privateCloudDatabase: syncEngine.container.privateCloudDatabase as! MockCloudDatabase, - sharedCloudDatabase: syncEngine.container.sharedCloudDatabase as! MockCloudDatabase - ), + container: syncEngine.container, privateDatabase: syncEngine.container.privateCloudDatabase as! MockCloudDatabase, sharedDatabase: syncEngine.container.sharedCloudDatabase as! MockCloudDatabase, userDatabase: self.userDatabase, diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index fa9ea851..5178b925 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -8,7 +8,7 @@ import Testing @Suite( .snapshots(record: .missing), - .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)) + .dependency(\.date.now, Date(timeIntervalSince1970: 0)) ) class BaseCloudKitTests: @unchecked Sendable { let userDatabase: UserDatabase diff --git a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift index 6c1c4c20..e3e34478 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift @@ -29,13 +29,19 @@ || !key.hasPrefix(CKRecord.userModificationDateKey) } .sorted { lhs, rhs in - ( - lhs.hasPrefix(CKRecord.userModificationDateKey) ? 1 : 0, - lhs - ) < ( - rhs.hasPrefix(CKRecord.userModificationDateKey) ? 1 : 0, - rhs - ) + guard lhs != CKRecord.userModificationDateKey + else { return false } + guard rhs != CKRecord.userModificationDateKey + else { return true } + let lhsHasPrefix = lhs.hasPrefix(CKRecord.userModificationDateKey) + let baseLHS = lhsHasPrefix + ? String(lhs.dropFirst(CKRecord.userModificationDateKey.count + 1)) + : lhs + let rhsHasPrefix = rhs.hasPrefix(CKRecord.userModificationDateKey) + let baseRHS = rhsHasPrefix + ? String(rhs.dropFirst(CKRecord.userModificationDateKey.count + 1)) + : rhs + return (baseLHS, lhsHasPrefix ? 1 : 0) < (baseRHS, rhsHasPrefix ? 1 : 0) } return Mirror( self, @@ -47,7 +53,15 @@ ] + keys .map { - ($0, self.encryptedValues[$0] as Any) + $0.hasPrefix(CKRecord.userModificationDateKey) + ? ( + String($0.dropFirst(CKRecord.userModificationDateKey.count + 1)) + "🗓️", + (self.encryptedValues[$0] as? Date).map(\.timeIntervalSince1970).map(Int.init) as Any + ) + : ( + $0, + self.encryptedValues[$0] as Any + ) }, displayStyle: .struct ) diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 1aad478c..7bccd8e7 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -503,11 +503,27 @@ private func comparePendingDatabaseChange( } extension SyncEngine { + @_disfavoredOverload func modifyRecords( scope: CKDatabase.Scope, saving recordsToSave: [CKRecord] = [], deleting recordIDsToDelete: [CKRecord.ID] = [] ) async { + await modifyRecords(scope: scope, saving: recordsToSave, deleting: recordIDsToDelete)() + } + + struct ModifyRecordsCallback { + let operation: @Sendable () async -> Void + func callAsFunction() async { + await operation() + } + } + + func modifyRecords( + scope: CKDatabase.Scope, + saving recordsToSave: [CKRecord] = [], + deleting recordIDsToDelete: [CKRecord.ID] = [] + ) -> ModifyRecordsCallback { let syncEngine = syncEngine(for: scope) let recordsToDeleteByID = Dictionary( grouping: syncEngine.database.storage.withValue { storage in @@ -515,28 +531,30 @@ extension SyncEngine { }, by: \.recordID ) - .compactMapValues(\.first) + .compactMapValues(\.first) let (saveResults, deleteResults) = syncEngine.database.modifyRecords( saving: recordsToSave, deleting: recordIDsToDelete ) - await syncEngine.delegate.handleEvent( - .fetchedRecordZoneChanges( - modifications: saveResults.values.compactMap { try? $0.get() }, - deletions: deleteResults.compactMap { recordID, result in - syncEngine.database.storage.withValue { storage in - (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in - (try? result.get()) != nil + return ModifyRecordsCallback { + await syncEngine.delegate.handleEvent( + .fetchedRecordZoneChanges( + modifications: saveResults.values.compactMap { try? $0.get() }, + deletions: deleteResults.compactMap { recordID, result in + syncEngine.database.storage.withValue { storage in + (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in + (try? result.get()) != nil ? (recordID, recordType) : nil + } } } - } - ), - syncEngine: syncEngine - ) + ), + syncEngine: syncEngine + ) + } } func processBatch( From 6094611b72a939736c7da119cc65946e6980ca43 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 3 Jul 2025 12:22:49 -0400 Subject: [PATCH 308/581] wip --- .../SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 59e307a5..7504f560 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -185,7 +185,7 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - guard Swift.max(encryptedValues[at: key], encryptedValues[at: key]) < userModificationDate + guard encryptedValues[at: key] < userModificationDate else { return false } if encryptedValues[key] != nil { encryptedValues[key] = nil From b17f86cfeec82d30db749288d08504d5377efe9d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 3 Jul 2025 17:08:28 -0400 Subject: [PATCH 309/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 226 +++++++++++------ .../CloudKit/Metadatabase.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 46 +++- .../CloudKitTests/MergeConflictTests.swift | 231 ++++++++++++++++++ .../CloudKitTests/SyncEngineSetUpTests.swift | 140 ++++++----- .../Internal/BaseCloudKitTests.swift | 2 +- 6 files changed, 487 insertions(+), 160 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index b6442a1a..17a04c8e 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -126,6 +126,84 @@ extension CKRecordKeyValueSetting { } } +extension CKRecord { + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + package func merge>( + from record: CKRecord? = nil, + tableRow: T, + userModificationDate: Date + ) { + typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable + + self.userModificationDate = userModificationDate + for column in T.TableColumns.allColumns { + func open(_ column: some TableColumnExpression) { + let key = column.name + let column = column as! any TableColumnExpression + let lastKnownValue = record?.encryptedValues[key] + let localTableValue = Value(queryOutput: tableRow[keyPath: column.keyPath]) + let localValue: (any CKRecordValueProtocol)? = switch localTableValue.queryBinding { + case .blob(let value): + value + case .double(let value): + value + case .date(let value): + value + case .int(let value): + value + case .null: + nil + case .text(let value): + value + case .uuid(let value): + value.uuidString.lowercased() + case .invalid(_): + // TODO: make `merge` throwing or report error? + nil + } + + + if record != nil, SharingGRDBCore.isEqual(lastKnownValue, localValue) == false { + + } + + // if lastKnowValue != localValue, merge table value into self + // otherwise, check timestamps and merge record value into self + + switch localTableValue.queryBinding { + case .blob(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .double(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .date(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .int(let value): + if record != nil, SharingGRDBCore.isEqual(lastKnownValue, localValue) == false { + encryptedValues[key] = localValue + encryptedValues[at: key] = userModificationDate + } else { + setValue(value, forKey: column.name, at: userModificationDate) + } + case .null: + removeValue(forKey: column.name, at: userModificationDate) + case .text(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .uuid(let value): + setValue( + value.uuidString.lowercased(), + forKey: column.name, + at: userModificationDate + ) + case .invalid(let error): + reportIssue(error) + } + } + open(column) + } + } + +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { @discardableResult @@ -136,9 +214,8 @@ extension CKRecord { ) -> Bool { print("!!!") guard - encryptedValues[at: key] < userModificationDate -// , -// encryptedValues[key] != newValue + encryptedValues[at: key] < userModificationDate, + encryptedValues[key] != newValue else { return false } encryptedValues[key] = newValue encryptedValues[at: key] = userModificationDate @@ -221,6 +298,7 @@ extension CKRecord { case .null: removeValue(forKey: column.name, at: userModificationDate) case .text(let value): + // self t=1, t=2 setValue(value, forKey: column.name, at: userModificationDate) case .uuid(let value): setValue( @@ -245,6 +323,31 @@ extension CKRecord { } } +// package func update>( +// with other: CKRecord, +// row: T, +// columnNames: inout [String] +// ) { +// typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable +// +// self.userModificationDate = other.userModificationDate +// for key in other.versionedKeys() { +// let didSet = if let value = other[key] as? CKAsset { +// setValue(value, forKey: key, at: other.encryptedValues[at: key]) +// } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { +// setValue(value, forKey: key, at: other.encryptedValues[at: key]) +// } else if other.encryptedValues[key] == nil { +// removeValue(forKey: key, at: other.encryptedValues[at: key]) +// } else { +// false +// } +// if didSet { +// columnNames.removeAll(where: { $0 == key }) +// } +// } +// } + + package func update2>( with other: CKRecord, row: T, @@ -257,93 +360,56 @@ extension CKRecord { for column in T.TableColumns.allColumns { func open(_ column: some TableColumnExpression) { let key = column.name + if key == "title" { + print("!!!") + } let column = column as! any TableColumnExpression let localValue = Value(queryOutput: row[keyPath: column.keyPath]) let lastKnownValue = other.encryptedValues[key] as? Value - - print(key, localValue, lastKnownValue) - print("!!!") - if key == "title" || key == "isCompleted" { - print("!!!!") - } - var didSet = if let value = other[key] as? CKAsset { - setValue(value, forKey: key, at: other.encryptedValues[at: key]) + let didSet: Bool + if let value = other[key] as? CKAsset { + didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { - setValue(value, forKey: key, at: other.encryptedValues[at: key]) + print(encryptedValues[key], value, key) + didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) } else if other.encryptedValues[key] == nil { - removeValue(forKey: key, at: other.encryptedValues[at: key]) + didSet = removeValue(forKey: key, at: other.encryptedValues[at: key]) } else { - false - } - if !didSet && (SharingGRDBCore.isEqual(localValue, lastKnownValue) == false) { - switch localValue.queryBinding { - case .blob(_): - fatalError() - case .double(let value): - encryptedValues[key] = value - case .date(let value): - encryptedValues[key] = value - case .int(let value): - encryptedValues[key] = value - case .null: - encryptedValues[key] = nil - case .text(let value): - encryptedValues[key] = value - case .uuid(let value): - encryptedValues[key] = value.uuidString.lowercased() - case .invalid: - fatalError() - } + didSet = false } - if didSet { - columnNames.removeAll(where: { $0 == key }) - } - -// switch localValue.queryBinding { -// case .blob(let value): -// setValue(value, forKey: column.name, at: userModificationDate) -// -// case .double(let value): -// setValue(value, forKey: column.name, at: userModificationDate) -// case .date(let value): -// setValue(value, forKey: column.name, at: userModificationDate) -// case .int(let value): -// setValue(value, forKey: column.name, at: userModificationDate) -// case .null: -// removeValue(forKey: column.name, at: userModificationDate) -// case .text(let value): -// setValue(value, forKey: column.name, at: userModificationDate) -// case .uuid(let value): -// setValue( -// value.uuidString.lowercased(), -// forKey: column.name, -// at: userModificationDate -// ) -// case .invalid(let error): -// reportIssue(error) -// false +// if key != "id" && !didSet && { +// switch localValue.queryBinding { +// case .blob(_): +// fatalError() +// case .double(let value): +//// encryptedValues[key] = value +//// encryptedValues[at: key] = other.encryptedValues[at: key] +// setValue(value, forKey: key, at: userModificationDate) +// case .date(let value): +// encryptedValues[key] = value +// case .int(let value): +// encryptedValues[key] = value +// case .null: +// encryptedValues[key] = nil +// case .text(let value): +//// encryptedValues[key] = value +// setValue(value, forKey: key, at: userModificationDate) +// case .uuid(let value): +// encryptedValues[key] = value.uuidString.lowercased() +// case .invalid: +// fatalError() +// } // } + // TODO: Handle lastKnownValue correctly, currently cast to Value fails on anything not base SQLite type + if (key == "id" || key == "remindersListID") { + return + } + if (didSet || !(SharingGRDBCore.isEqual(localValue, lastKnownValue) ?? false)) { + columnNames.removeAll(where: { $0 == key }) + } } open(column) } - -// for key in other.versionedKeys() { -// if key == "title" || key == "isCompleted" { -// print("!!!!") -// } -// var didSet = if let value = other[key] as? CKAsset { -// setValue(value, forKey: key, at: other.encryptedValues[at: key]) -// } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { -// setValue(value, forKey: key, at: other.encryptedValues[at: key]) -// } else if other.encryptedValues[key] == nil { -// removeValue(forKey: key, at: other.encryptedValues[at: key]) -// } else { -// false -// } -// if didSet { -// columnNames.removeAll(where: { $0 == key }) -// } -// } } package var userModificationDate: Date { @@ -386,7 +452,7 @@ extension CKRecord { #endif -private func isEqual(_ lhs: T, _ rhs: T) -> Bool? { +private func isEqual(_ lhs: Any?, _ rhs: Any?) -> Bool? { guard let lhs = lhs as? any Equatable else { return nil } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 94458e18..d9e02863 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -80,7 +80,7 @@ func defaultMetadatabase( } extension QueryFragment { - fileprivate static func datetime() -> Self { + static func datetime() -> Self { Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 9276e7be..9d7c2675 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -297,9 +297,7 @@ } private func fetchChangesFromSchemaChange(recordTypes: [RecordType]) async throws { - // TODO: do batches for sake of CKDatabase - // only docs we found was about modifies: https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation - // recommends limiting to <400 records and <2mb data posted + // TODO: update data from local server records, do not fetch from CloudKit let lastKnownServerRecords = try await metadatabase.read { db in try SyncMetadata .where { @@ -673,10 +671,15 @@ action: .none ) } + record.update( with: T(queryOutput: row), userModificationDate: metadata.userModificationDate ) +// record.merge( +// tableRow: T(queryOutput: row), +// userModificationDate: metadata.userModificationDate +// ) await refreshLastKnownServerRecord(record) sentRecord = recordID return record @@ -962,13 +965,23 @@ } guard let recordName = SyncMetadata.RecordName(recordID: serverRecord.recordID) else { return } - let allFields = try metadatabase.read { db in - try SyncMetadata - .find(recordName) - .select(\._lastKnownServerRecordAllFields) - .fetchOne(db) - } - ?? nil +// let metadata = try metadatabase.read { db in +// try SyncMetadata +// .find(recordName) +// .select(\._lastKnownServerRecordAllFields) +// .fetchOne(db) +// } +// ?? nil + + let result = try metadatabase.read { db in + try SyncMetadata + .find(recordName) + .select { ($0, $0._lastKnownServerRecordAllFields) } + .fetchOne(db) + } + let metadata = result?.0 + let allFields = result?.1 + serverRecord.userModificationDate = metadata?.userModificationDate ?? serverRecord.userModificationDate func open>(_: T.Type) throws { var columnNames = T.TableColumns.allColumns.map(\.name) @@ -982,7 +995,18 @@ fatalError() return } - serverRecord.update2(with: allFields, row: T(queryOutput: row), columnNames: &columnNames) + + serverRecord.update2( + with: allFields, + row: T(queryOutput: row), + columnNames: &columnNames + ) + +// serverRecord.merge( +// from: allFields, +// tableRow: T(queryOutput: row), +// userModificationDate: allFields.userModificationDate +// ) } var query: QueryFragment = "INSERT INTO \(T.self) (" diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 43291bb3..7f1ddcb5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -390,5 +390,236 @@ extension BaseCloudKitTests { // // assertInlineSnapshot(of: syncEngine.container, as: .customDump) } + + @Test func bar() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "") + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let userModificationDate = now.addingTimeInterval(60) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + record.userModificationDate = userModificationDate + let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() + + try await withDependencies { + $0.date.now = now.addingTimeInterval(30) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.title = "Get milk" }.execute(db) + } + } + await modificationCallback() + await syncEngine.processBatch() + + try await userDatabase.userWrite { db in + try #expect( + Reminder.find(UUID(1)).fetchOne(db) + == Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + ) + } + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test func baz() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "") + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let userModificationDate = now.addingTimeInterval(30) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + record.userModificationDate = userModificationDate + let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() + + try await withDependencies { + $0.date.now = now.addingTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.title = "Get milk" }.execute(db) + } + } + await modificationCallback() + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + +// await syncEngine.processBatch() +// +// assertInlineSnapshot(of: syncEngine.container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [ +// [0]: CKRecord( +// recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), +// recordType: "reminders", +// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), +// share: nil, +// id: "00000000-0000-0000-0000-000000000001", +// id🗓️: 0, +// isCompleted: 0, +// isCompleted🗓️: 0, +// remindersListID: "00000000-0000-0000-0000-000000000001", +// remindersListID🗓️: 0, +// title: "Buy milk", +// title🗓️: 30, +// 🗓️: 30 +// ), +// [1]: CKRecord( +// recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), +// recordType: "remindersLists", +// parent: nil, +// share: nil, +// id: "00000000-0000-0000-0000-000000000001", +// id🗓️: 0, +// title: "", +// title🗓️: 0, +// 🗓️: 0 +// ) +// ] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index c336d049..cbbeefd6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -9,6 +9,8 @@ import Testing extension BaseCloudKitTests { @MainActor final class SetUpTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.date.now) var now + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func schemaChange() async throws { let personalList = RemindersList(id: UUID(1), title: "Personal") @@ -24,89 +26,93 @@ extension BaseCloudKitTests { await syncEngine.processBatch() - let personalListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: UUID(1)) - ) - personalListRecord.userModificationDate = Date() - personalListRecord.setValue(1, forKey: "position", at: Date()) + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + let personalListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: UUID(1)) + ) + personalListRecord.userModificationDate = now + personalListRecord.setValue(1, forKey: "position", at: now) - let businessListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: UUID(2)) - ) - businessListRecord.userModificationDate = Date() - businessListRecord.setValue(2, forKey: "position", at: Date()) + let businessListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: UUID(2)) + ) + businessListRecord.userModificationDate = now + businessListRecord.setValue(2, forKey: "position", at: now) - let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: UUID(1)) - ) - reminderRecord.userModificationDate = Date() - reminderRecord.setValue(3, forKey: "position", at: Date()) + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: UUID(1)) + ) + reminderRecord.userModificationDate = now + reminderRecord.setValue(3, forKey: "position", at: now) - _ = await syncEngine.modifyRecords( - scope: .private, - saving: [personalListRecord, businessListRecord, reminderRecord] - ) + await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord, businessListRecord, reminderRecord] + ) - try await userDatabase.userWrite { db in - try #sql( + try await userDatabase.userWrite { db in + try #sql( """ ALTER TABLE "remindersLists" ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 """ - ) - .execute(db) - try #sql( + ) + .execute(db) + try #sql( """ ALTER TABLE "reminders" ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 """ - ) - .execute(db) - } + ) + .execute(db) + } - let relaunchedSyncEngine = try await SyncEngine( - container: syncEngine.container, - privateDatabase: syncEngine.container.privateCloudDatabase as! MockCloudDatabase, - sharedDatabase: syncEngine.container.sharedCloudDatabase as! MockCloudDatabase, - userDatabase: self.userDatabase, - metadatabaseURL: URL - .metadatabase(containerIdentifier: syncEngine.container.containerIdentifier!), - tables: [ - MigratedReminder.self, - MigratedRemindersList.self, - Tag.self, - ReminderTag.self, - Parent.self, - ChildWithOnDeleteRestrict.self, - ChildWithOnDeleteSetNull.self, - ChildWithOnDeleteSetDefault.self, - ], - privateTables: [ - RemindersListPrivate.self - ] - ) + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + privateDatabase: syncEngine.container.privateCloudDatabase as! MockCloudDatabase, + sharedDatabase: syncEngine.container.sharedCloudDatabase as! MockCloudDatabase, + userDatabase: self.userDatabase, + metadatabaseURL: URL + .metadatabase(containerIdentifier: syncEngine.container.containerIdentifier!), + tables: [ + MigratedReminder.self, + MigratedRemindersList.self, + Tag.self, + ReminderTag.self, + Parent.self, + ChildWithOnDeleteRestrict.self, + ChildWithOnDeleteSetNull.self, + ChildWithOnDeleteSetDefault.self, + ], + privateTables: [ + RemindersListPrivate.self + ] + ) - await relaunchedSyncEngine.processBatch() + await relaunchedSyncEngine.processBatch() - let remindersLists = try await userDatabase.userRead { db in - try MigratedRemindersList.order(by: \.id).fetchAll(db) - } - let reminders = try await userDatabase.userRead { db in - try MigratedReminder.order(by: \.id).fetchAll(db) + let remindersLists = try await userDatabase.userRead { db in + try MigratedRemindersList.order(by: \.id).fetchAll(db) + } + let reminders = try await userDatabase.userRead { db in + try MigratedReminder.order(by: \.id).fetchAll(db) + } + expectNoDifference( + remindersLists, + [ + MigratedRemindersList(id: UUID(1), title: "Personal", position: 1), + MigratedRemindersList(id: UUID(2), title: "Business", position: 2), + ] + ) + expectNoDifference( + reminders, + [ + MigratedReminder(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), + ] + ) } - expectNoDifference( - remindersLists, - [ - MigratedRemindersList(id: UUID(1), title: "Personal", position: 1), - MigratedRemindersList(id: UUID(2), title: "Business", position: 2), - ] - ) - expectNoDifference( - reminders, - [ - MigratedReminder(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), - ] - ) } } } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 5178b925..0fad3976 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,7 @@ import SnapshotTesting import Testing @Suite( - .snapshots(record: .missing), + .snapshots(record: .failed), .dependency(\.date.now, Date(timeIntervalSince1970: 0)) ) class BaseCloudKitTests: @unchecked Sendable { From f2fd6cf0b34ac6287304af1ad25d99334b363763 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 3 Jul 2025 17:15:40 -0400 Subject: [PATCH 310/581] wip --- .../SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 34604166..d91947a4 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -404,7 +404,8 @@ extension CKRecord { if (key == "id" || key == "remindersListID") { return } - if (didSet || !(SharingGRDBCore.isEqual(localValue, lastKnownValue) ?? false)) { + let localValueIsNewerThanServer = didSet || !(SharingGRDBCore.isEqual(localValue, lastKnownValue) ?? false) + if localValueIsNewerThanServer { columnNames.removeAll(where: { $0 == key }) } } From 611431230ba5e0670f8d03465a5c24a9e2025330 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 3 Jul 2025 20:40:18 -0400 Subject: [PATCH 311/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 179 +++--------------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 33 +--- 2 files changed, 37 insertions(+), 175 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index d91947a4..d410013b 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -126,84 +126,6 @@ extension CKRecordKeyValueSetting { } } -extension CKRecord { - @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) - package func merge>( - from record: CKRecord? = nil, - tableRow: T, - userModificationDate: Date - ) { - typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable - - self.userModificationDate = userModificationDate - for column in T.TableColumns.allColumns { - func open(_ column: some TableColumnExpression) { - let key = column.name - let column = column as! any TableColumnExpression - let lastKnownValue = record?.encryptedValues[key] - let localTableValue = Value(queryOutput: tableRow[keyPath: column.keyPath]) - let localValue: (any CKRecordValueProtocol)? = switch localTableValue.queryBinding { - case .blob(let value): - value - case .double(let value): - value - case .date(let value): - value - case .int(let value): - value - case .null: - nil - case .text(let value): - value - case .uuid(let value): - value.uuidString.lowercased() - case .invalid(_): - // TODO: make `merge` throwing or report error? - nil - } - - - if record != nil, SharingGRDBCore.isEqual(lastKnownValue, localValue) == false { - - } - - // if lastKnowValue != localValue, merge table value into self - // otherwise, check timestamps and merge record value into self - - switch localTableValue.queryBinding { - case .blob(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .double(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .date(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .int(let value): - if record != nil, SharingGRDBCore.isEqual(lastKnownValue, localValue) == false { - encryptedValues[key] = localValue - encryptedValues[at: key] = userModificationDate - } else { - setValue(value, forKey: column.name, at: userModificationDate) - } - case .null: - removeValue(forKey: column.name, at: userModificationDate) - case .text(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .uuid(let value): - setValue( - value.uuidString.lowercased(), - forKey: column.name, - at: userModificationDate - ) - case .invalid(let error): - reportIssue(error) - } - } - open(column) - } - } - -} - @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { @discardableResult @@ -212,7 +134,6 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - print("!!!") guard encryptedValues[at: key] < userModificationDate, encryptedValues[key] != newValue @@ -228,7 +149,6 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - print("!!!") guard encryptedValues[at: key] < userModificationDate else { return false } let hash = SHA256.hash(data: newValue).compactMap { String(format: "%02hhx", $0) }.joined() let blobURL = URL.temporaryDirectory.appendingPathComponent(hash) @@ -250,7 +170,6 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - print("!!!") guard encryptedValues[at: key] < userModificationDate, (self[key] as? CKAsset)?.fileURL != newValue.fileURL @@ -265,7 +184,6 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { - print("!!!") guard encryptedValues[at: key] < userModificationDate else { return false } if encryptedValues[key] != nil { @@ -323,32 +241,7 @@ extension CKRecord { } } -// package func update>( -// with other: CKRecord, -// row: T, -// columnNames: inout [String] -// ) { -// typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable -// -// self.userModificationDate = other.userModificationDate -// for key in other.versionedKeys() { -// let didSet = if let value = other[key] as? CKAsset { -// setValue(value, forKey: key, at: other.encryptedValues[at: key]) -// } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { -// setValue(value, forKey: key, at: other.encryptedValues[at: key]) -// } else if other.encryptedValues[key] == nil { -// removeValue(forKey: key, at: other.encryptedValues[at: key]) -// } else { -// false -// } -// if didSet { -// columnNames.removeAll(where: { $0 == key }) -// } -// } -// } - - - package func update2>( + package func update>( with other: CKRecord, row: T, columnNames: inout [String] @@ -356,58 +249,46 @@ extension CKRecord { typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable self.userModificationDate = other.userModificationDate - for column in T.TableColumns.allColumns { func open(_ column: some TableColumnExpression) { let key = column.name - if key == "title" { - print("!!!") - } let column = column as! any TableColumnExpression - let localValue = Value(queryOutput: row[keyPath: column.keyPath]) - let lastKnownValue = other.encryptedValues[key] as? Value let didSet: Bool if let value = other[key] as? CKAsset { didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { - print(encryptedValues[key], value, key) didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) } else if other.encryptedValues[key] == nil { didSet = removeValue(forKey: key, at: other.encryptedValues[at: key]) } else { didSet = false } -// if key != "id" && !didSet && { -// switch localValue.queryBinding { -// case .blob(_): -// fatalError() -// case .double(let value): -//// encryptedValues[key] = value -//// encryptedValues[at: key] = other.encryptedValues[at: key] -// setValue(value, forKey: key, at: userModificationDate) -// case .date(let value): -// encryptedValues[key] = value -// case .int(let value): -// encryptedValues[key] = value -// case .null: -// encryptedValues[key] = nil -// case .text(let value): -//// encryptedValues[key] = value -// setValue(value, forKey: key, at: userModificationDate) -// case .uuid(let value): -// encryptedValues[key] = value.uuidString.lowercased() -// case .invalid: -// fatalError() -// } -// } - // TODO: Handle lastKnownValue correctly, currently cast to Value fails on anything not base SQLite type - if (key == "id" || key == "remindersListID") { - return - } - let localValueIsNewerThanServer = didSet || !(SharingGRDBCore.isEqual(localValue, lastKnownValue) ?? false) - if localValueIsNewerThanServer { - columnNames.removeAll(where: { $0 == key }) + let lastKnownValue = other.encryptedValues[key] + var localValue: (any CKRecordValueProtocol)? { + let value = Value(queryOutput: row[keyPath: column.keyPath]) + switch value.queryBinding { + case .blob(let value): + return value + case .double(let value): + return value + case .date(let value): + return value + case .int(let value): + return value + case .null: + return nil + case .text(let value): + return value + case .uuid(let value): + return value.uuidString.lowercased() + case .invalid(_): + // TODO: make `merge` throwing or report error? + return nil } + } + if didSet || !_isEqual(localValue, lastKnownValue) { + columnNames.removeAll(where: { $0 == key }) + } } open(column) } @@ -453,12 +334,12 @@ extension CKRecord { #endif -private func isEqual(_ lhs: Any?, _ rhs: Any?) -> Bool? { +private func _isEqual(_ lhs: Any?, _ rhs: Any?) -> Bool { guard let lhs = lhs as? any Equatable - else { return nil } + else { return false } - func open(_ lhs: S) -> Bool? { - (rhs as? S).map { lhs == $0 } + func open(_ lhs: S) -> Bool { + (rhs as? S).map { lhs == $0 } ?? false } return open(lhs) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 9d7c2675..785defc0 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -676,10 +676,6 @@ with: T(queryOutput: row), userModificationDate: metadata.userModificationDate ) -// record.merge( -// tableRow: T(queryOutput: row), -// userModificationDate: metadata.userModificationDate -// ) await refreshLastKnownServerRecord(record) sentRecord = recordID return record @@ -965,48 +961,33 @@ } guard let recordName = SyncMetadata.RecordName(recordID: serverRecord.recordID) else { return } -// let metadata = try metadatabase.read { db in -// try SyncMetadata -// .find(recordName) -// .select(\._lastKnownServerRecordAllFields) -// .fetchOne(db) -// } -// ?? nil let result = try metadatabase.read { db in - try SyncMetadata - .find(recordName) - .select { ($0, $0._lastKnownServerRecordAllFields) } - .fetchOne(db) - } + try SyncMetadata + .find(recordName) + .select { ($0, $0._lastKnownServerRecordAllFields) } + .fetchOne(db) + } let metadata = result?.0 let allFields = result?.1 serverRecord.userModificationDate = metadata?.userModificationDate ?? serverRecord.userModificationDate func open>(_: T.Type) throws { var columnNames = T.TableColumns.allColumns.map(\.name) - if let allFields { let row = try userDatabase.read { db in try T.find(recordName.id).fetchOne(db) } guard let row else { - fatalError() + reportIssue("Local database record could not be found for '\(recordName.rawValue)'.") return } - - serverRecord.update2( + serverRecord.update( with: allFields, row: T(queryOutput: row), columnNames: &columnNames ) - -// serverRecord.merge( -// from: allFields, -// tableRow: T(queryOutput: row), -// userModificationDate: allFields.userModificationDate -// ) } var query: QueryFragment = "INSERT INTO \(T.self) (" From c7553e7e954007671e396f650ac01de8d4bf5dc4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 3 Jul 2025 20:40:53 -0400 Subject: [PATCH 312/581] wip --- .../CloudKitTests/SyncEngineSetUpTests.swift | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index cbbeefd6..8b90a008 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -34,24 +34,24 @@ extension BaseCloudKitTests { ) personalListRecord.userModificationDate = now personalListRecord.setValue(1, forKey: "position", at: now) - + let businessListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: UUID(2)) ) businessListRecord.userModificationDate = now businessListRecord.setValue(2, forKey: "position", at: now) - + let reminderRecord = try syncEngine.private.database.record( for: Reminder.recordID(for: UUID(1)) ) reminderRecord.userModificationDate = now reminderRecord.setValue(3, forKey: "position", at: now) - + await syncEngine.modifyRecords( scope: .private, saving: [personalListRecord, businessListRecord, reminderRecord] ) - + try await userDatabase.userWrite { db in try #sql( """ @@ -68,7 +68,7 @@ extension BaseCloudKitTests { ) .execute(db) } - + let relaunchedSyncEngine = try await SyncEngine( container: syncEngine.container, privateDatabase: syncEngine.container.privateCloudDatabase as! MockCloudDatabase, @@ -90,28 +90,30 @@ extension BaseCloudKitTests { RemindersListPrivate.self ] ) - + await relaunchedSyncEngine.processBatch() - + let remindersLists = try await userDatabase.userRead { db in try MigratedRemindersList.order(by: \.id).fetchAll(db) } let reminders = try await userDatabase.userRead { db in try MigratedReminder.order(by: \.id).fetchAll(db) } - expectNoDifference( - remindersLists, - [ - MigratedRemindersList(id: UUID(1), title: "Personal", position: 1), - MigratedRemindersList(id: UUID(2), title: "Business", position: 2), - ] - ) - expectNoDifference( - reminders, - [ - MigratedReminder(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), - ] - ) + withKnownIssue("This will be fixed once we properly update user database with last fetched record when schema changes") { + expectNoDifference( + remindersLists, + [ + MigratedRemindersList(id: UUID(1), title: "Personal", position: 1), + MigratedRemindersList(id: UUID(2), title: "Business", position: 2), + ] + ) + expectNoDifference( + reminders, + [ + MigratedReminder(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), + ] + ) + } } } } From 9a621a494988e40e7712c79f726fca4e0d5ef438 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 3 Jul 2025 20:58:08 -0400 Subject: [PATCH 313/581] tests --- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../CloudKit/SyncMetadata.swift | 2 +- .../FetchRecordZoneChangesTests.swift | 127 ++++++++++++++++++ .../CloudKitTests/SyncEngineSetUpTests.swift | 2 +- 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index d410013b..80ad0344 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -15,7 +15,7 @@ extension _CKRecord where Self == CKShare { } extension Optional where Wrapped: CKRecord { - typealias AllFieldsRepresentation = _AllFieldsRepresentation? + package typealias AllFieldsRepresentation = _AllFieldsRepresentation? public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index ceb30cc5..95953d0c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -125,7 +125,7 @@ extension SyncMetadata.TableColumns { SQLQueryExpression("substr(\(parentRecordName), 38)") } - var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< + package var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< SyncMetadata, CKRecord?.AllFieldsRepresentation > { diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift new file mode 100644 index 00000000..4f95c6b5 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -0,0 +1,127 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import OrderedCollections +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + @Suite(.printTimestamps) final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.date.now) var now + + @Test func saveExtraFieldsToSyncMetadata() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: UUID(1))) + reminderRecord.setValue("Hello world! 🌎🌎🌎", forKey: "newField", at: now) + + await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + + do { + let lastKnownServerRecords = try await syncEngine.metadatabase.read { db in + try SyncMetadata + .order(by: \.recordName) + .select(\._lastKnownServerRecordAllFields) + .fetchAll(db) + } + assertInlineSnapshot(of: lastKnownServerRecords, as: .customDump) { + """ + [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + newField: "Hello world! 🌎🌎🌎", + newField🗓️: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 0, + 🗓️: 0 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "Personal", + title🗓️: 0, + 🗓️: 0 + ) + ] + """ + } + } + + try await withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.isCompleted.toggle() }.execute(db) + } + + await syncEngine.processBatch() + + do { + let lastKnownServerRecords = try await syncEngine.metadatabase.read { db in + try SyncMetadata + .order(by: \.recordName) + .select(\._lastKnownServerRecordAllFields) + .fetchAll(db) + } + assertInlineSnapshot(of: lastKnownServerRecords, as: .customDump) { + """ + [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 1, + isCompleted🗓️: 1, + newField: "Hello world! 🌎🌎🌎", + newField🗓️: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 0, + 🗓️: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "Personal", + title🗓️: 0, + 🗓️: 0 + ) + ] + """ + } + } + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index 8b90a008..9f2127cc 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -51,7 +51,7 @@ extension BaseCloudKitTests { scope: .private, saving: [personalListRecord, businessListRecord, reminderRecord] ) - + try await userDatabase.userWrite { db in try #sql( """ From 5e22b918fad4e03a8da2993280564679de3474b5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 6 Jul 2025 16:18:58 -0700 Subject: [PATCH 314/581] cleaned up stuff and fixed a todo with tests --- .../CloudKit/CloudKit+StructuredQueries.swift | 34 +-- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 47 ++-- .../CloudKitTests/CloudKitTests.swift | 2 +- .../FetchRecordZoneChangesTests.swift | 211 +++++++++++++++++- .../Internal/BaseCloudKitTests.swift | 2 +- 5 files changed, 235 insertions(+), 61 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 80ad0344..6932118d 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -232,15 +232,6 @@ extension CKRecord { } } - func versionedKeys() -> [FieldKey] { - allKeys() - .compactMap { - $0.hasPrefix("\(Self.userModificationDateKey)_") - ? String($0.dropFirst("\(Self.userModificationDateKey)_".count)) - : nil - } - } - package func update>( with other: CKRecord, row: T, @@ -267,22 +258,15 @@ extension CKRecord { var localValue: (any CKRecordValueProtocol)? { let value = Value(queryOutput: row[keyPath: column.keyPath]) switch value.queryBinding { - case .blob(let value): - return value - case .double(let value): - return value - case .date(let value): - return value - case .int(let value): - return value - case .null: - return nil - case .text(let value): - return value - case .uuid(let value): - return value.uuidString.lowercased() - case .invalid(_): - // TODO: make `merge` throwing or report error? + case .blob(let value): return value + case .double(let value): return value + case .date(let value): return value + case .int(let value): return value + case .null: return nil + case .text(let value): return value + case .uuid(let value): return value.uuidString.lowercased() + case .invalid(let error): + reportIssue(error) return nil } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 785defc0..409e736c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -855,10 +855,7 @@ try userDatabase.write { db in try SyncMetadata .find(recordName) - .update { - $0.lastKnownServerRecord = nil - $0._lastKnownServerRecordAllFields = nil - } + .update { $0.setLastKnownServerRecord(nil) } .execute(db) } } @@ -1022,30 +1019,15 @@ // TODO: Use WHERE to scope the update? try userDatabase.write { db in try SQLQueryExpression(query).execute(db) - // TODO: Do we need to update parentRecordName too in case it changed? try SyncMetadata - .insert { - ( - $0.recordType, - $0.recordName, - $0.lastKnownServerRecord, - $0._lastKnownServerRecordAllFields, - $0.userModificationDate - ) - } values: { - ( - serverRecord.recordType, - recordName, - serverRecord, - serverRecord, - serverRecord.userModificationDate - ) - } onConflictDoUpdate: { - $0.lastKnownServerRecord = serverRecord - $0._lastKnownServerRecordAllFields = serverRecord - $0.userModificationDate = serverRecord.userModificationDate - } - .execute(db) + .find(recordName) + .update { + $0.parentRecordName = (serverRecord.parent?.recordID.recordName) + .flatMap(SyncMetadata.RecordName.init(rawValue:)) + $0.setLastKnownServerRecord(serverRecord) + $0.userModificationDate = serverRecord.userModificationDate + } + .execute(db) } } try open(table) @@ -1065,8 +1047,7 @@ try SyncMetadata .find(recordName) .update { - $0.lastKnownServerRecord = record - $0._lastKnownServerRecordAllFields = record + $0.setLastKnownServerRecord(record) $0.userModificationDate = record.userModificationDate } .execute(db) @@ -1381,4 +1362,12 @@ appendInterpolation(QueryValue(queryOutput: queryOutput)) } } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Updates { + mutating func setLastKnownServerRecord(_ lastKnownServerRecord: CKRecord?) { + self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = lastKnownServerRecord + } +} #endif diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index e0d28f36..7cb34415 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -531,7 +531,7 @@ extension BaseCloudKitTests { } let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) - _ = await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]) + await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]) #expect( try await userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchAll(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 4f95c6b5..72ff6518 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -9,7 +9,8 @@ import Testing extension BaseCloudKitTests { @MainActor - @Suite(.printTimestamps) final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { + @Suite(.printTimestamps) + final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { @Dependency(\.date.now) var now @Test func saveExtraFieldsToSyncMetadata() async throws { @@ -73,11 +74,11 @@ extension BaseCloudKitTests { try await withDependencies { $0.date.now.addTimeInterval(1) } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.isCompleted.toggle() }.execute(db) - } + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.isCompleted.toggle() }.execute(db) + } - await syncEngine.processBatch() + await syncEngine.processBatch() do { let lastKnownServerRecords = try await syncEngine.metadatabase.read { db in @@ -123,5 +124,205 @@ extension BaseCloudKitTests { } } } + + @Test func remoteChangeParentRelationship() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: UUID(2), title: "Business") + Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + + try await withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: UUID(1))) + reminderRecord.setValue(UUID(2).uuidString.lowercased(), forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: UUID(2)), + action: .none + ) + + await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } + + assertInlineSnapshot( + of: syncEngine.private.database.storage[Reminder.recordID(for: UUID(1))], + as: .customDump + ) { + """ + CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: "00000000-0000-0000-0000-000000000002", + remindersListID🗓️: 1, + title: "Get milk", + title🗓️: 0, + 🗓️: 0 + ) + """ + } + + try { + try userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(Reminder.recordName(for: UUID(1))).fetchOne(db) + ) + #expect(metadata.parentRecordName == RemindersList.recordName(for: UUID(2))) + let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) + #expect(reminder == Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(2))) + } + }() + + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.isCompleted.toggle() }.execute(db) + } + + await syncEngine.processBatch() + + assertInlineSnapshot( + of: syncEngine.private.database.storage[Reminder.recordID(for: UUID(1))], + as: .customDump + ) { + """ + CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: "00000000-0000-0000-0000-000000000002", + remindersListID🗓️: 1, + title: "Get milk", + title🗓️: 0, + 🗓️: 0 + ) + """ + } + + try { + try userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(Reminder.recordName(for: UUID(1))).fetchOne(db) + ) + #expect(metadata.parentRecordName == RemindersList.recordName(for: UUID(2))) + let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) + #expect( + reminder == Reminder( + id: UUID(1), + isCompleted: true, + title: "Get milk", + remindersListID: UUID(2) + ) + ) + } + }() + } + + @Test func receiveNewRecordFromCloudKit() async throws { + let remindersListRecord = CKRecord.init( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: UUID(1)) + ) + remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + remindersListRecord.userModificationDate = now + + await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "Personal", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) + ) + #expect(metadata.recordName == RemindersList.recordName(for: UUID(1))) + let remindersList = try #require(try RemindersList.find(UUID(1)).fetchOne(db)) + #expect(remindersList == RemindersList(id: UUID(1), title: "Personal")) + } + }() + + try await withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(UUID(1)).update { $0.title = "My stuff" }.execute(db) + } + + await syncEngine.processBatch() + } + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "My stuff", + title🗓️: 1, + 🗓️: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try userDatabase.read { db in + let remindersList = try #require(try RemindersList.find(UUID(1)).fetchOne(db)) + #expect(remindersList == RemindersList(id: UUID(1), title: "My stuff")) + } + }() + } } } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 0fad3976..5178b925 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,7 @@ import SnapshotTesting import Testing @Suite( - .snapshots(record: .failed), + .snapshots(record: .missing), .dependency(\.date.now, Date(timeIntervalSince1970: 0)) ) class BaseCloudKitTests: @unchecked Sendable { From 06762c3eebd422ee377e6bce51a817def755ddc7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 10:22:41 -0700 Subject: [PATCH 315/581] asset changes --- .../CloudKit/CloudKit+StructuredQueries.swift | 6 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 16 +- .../Internal/DataManager.swift | 66 +++++++ .../CloudKitTests/AssetsTests.swift | 169 ++++++++++++++++++ .../FetchRecordZoneChangesTests.swift | 106 +++++++++++ .../Internal/BaseCloudKitTests.swift | 6 +- .../Internal/CloudKit+CustomDump.swift | 28 +++ Tests/SharingGRDBTests/Internal/Schema.swift | 27 ++- 8 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 Sources/SharingGRDBCore/Internal/DataManager.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 6932118d..9c210206 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -149,14 +149,16 @@ extension CKRecord { forKey key: CKRecord.FieldKey, at userModificationDate: Date ) -> Bool { + @Dependency(\.dataManager) var dataManager + guard encryptedValues[at: key] < userModificationDate else { return false } let hash = SHA256.hash(data: newValue).compactMap { String(format: "%02hhx", $0) }.joined() - let blobURL = URL.temporaryDirectory.appendingPathComponent(hash) + let blobURL = dataManager.temporaryDirectory.appendingPathComponent(hash) let asset = CKAsset(fileURL: blobURL) guard (self[key] as? CKAsset)?.fileURL != blobURL else { return false } withErrorReporting { - try Data(newValue).write(to: blobURL) + try dataManager.save(Data(newValue), to: blobURL) } self[key] = asset encryptedValues[at: key] = userModificationDate diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 409e736c..76e0d292 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -995,7 +995,8 @@ columnNames .map { columnName in if let asset = serverRecord[columnName] as? CKAsset { - return (try? asset.fileURL.map { try Data(contentsOf: $0) })? + @Dependency(\.dataManager) var dataManager + return (try? asset.fileURL.map { try dataManager.load($0) })? .queryFragment ?? "NULL" } else { return encryptedValues[columnName]?.queryFragment ?? "NULL" @@ -1022,12 +1023,10 @@ try SyncMetadata .find(recordName) .update { - $0.parentRecordName = (serverRecord.parent?.recordID.recordName) - .flatMap(SyncMetadata.RecordName.init(rawValue:)) - $0.setLastKnownServerRecord(serverRecord) - $0.userModificationDate = serverRecord.userModificationDate - } - .execute(db) + $0.setLastKnownServerRecord(serverRecord) + $0.userModificationDate = serverRecord.userModificationDate + } + .execute(db) } } try open(table) @@ -1368,6 +1367,9 @@ extension Updates { mutating func setLastKnownServerRecord(_ lastKnownServerRecord: CKRecord?) { self.lastKnownServerRecord = lastKnownServerRecord self._lastKnownServerRecordAllFields = lastKnownServerRecord + if let lastKnownServerRecord { + self.userModificationDate = lastKnownServerRecord.userModificationDate + } } } #endif diff --git a/Sources/SharingGRDBCore/Internal/DataManager.swift b/Sources/SharingGRDBCore/Internal/DataManager.swift new file mode 100644 index 00000000..96e6a1db --- /dev/null +++ b/Sources/SharingGRDBCore/Internal/DataManager.swift @@ -0,0 +1,66 @@ +import Dependencies +import Foundation + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +package protocol DataManager: Sendable { + func load(_ url: URL) throws -> Data + func save(_ data: Data, to url: URL) throws + var temporaryDirectory: URL { get } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct LiveDataManager: DataManager { + func load(_ url: URL) throws -> Data { + try Data(contentsOf: url) + } + func save(_ data: Data, to url: URL) throws { + try data.write(to: url) + } + var temporaryDirectory: URL { + .temporaryDirectory + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +package struct InMemoryDataManager: DataManager { + package let storage = LockIsolated<[URL: Data]>([:]) + + package init() {} + + package func load(_ url: URL) throws -> Data { + try storage.withValue { storage in + guard let data = storage[url] + else { + struct FileNotFound: Error {} + throw FileNotFound() + } + return data + } + } + + package func save(_ data: Data, to url: URL) throws { + storage.withValue { $0[url] = data } + } + + package var temporaryDirectory: URL { + URL(filePath: "/") + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +private enum DataManagerKey: DependencyKey { + static var liveValue: any DataManager { + LiveDataManager() + } + static var testValue: any DataManager { + InMemoryDataManager() + } +} + +extension DependencyValues { + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + package var dataManager: DataManager { + get { self[DataManagerKey.self] } + set { self[DataManagerKey.self] = newValue } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift new file mode 100644 index 00000000..4215dd5c --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -0,0 +1,169 @@ +import CloudKit +import ConcurrencyExtras +import CustomDump +import InlineSnapshotTesting +import OrderedCollections +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class AssetsTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.date.now) var now + @Dependency(\.dataManager) var dataManager + var inMemoryDataManager: InMemoryDataManager { + dataManager as! InMemoryDataManager + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func basics() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + RemindersListAsset(id: UUID(1), coverImage: Data("image".utf8), remindersListID: UUID(1)) + } + } + + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListAssets/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersListAssets", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + remindersListID: "00000000-0000-0000-0000-000000000001", + coverImage: CKAsset( + fileURL: URL(file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d), + dataString: "image" + ) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + inMemoryDataManager.storage.withValue { storage in + let url = URL(string: "file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d")! + #expect(storage[url] == Data("image".utf8)) + } + + try await withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersListAsset + .find(UUID(1)) + .update { $0.coverImage = Data("new-image".utf8) } + .execute(db) + } + } + + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListAssets/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersListAssets", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + remindersListID: "00000000-0000-0000-0000-000000000001", + coverImage: CKAsset( + fileURL: URL(file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf), + dataString: "new-image" + ) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + inMemoryDataManager.storage.withValue { storage in + let url = URL(string: "file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf")! + #expect(storage[url] == Data("new-image".utf8)) + } + } + + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveAsset() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: UUID(1)) + ) + remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let remindersListAssetRecord = CKRecord( + recordType: RemindersListAsset.tableName, + recordID: RemindersListAsset.recordID(for: UUID(1)) + ) + remindersListAssetRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListAssetRecord.setValue( + Array("image".utf8), + forKey: "coverImage", + at: now + ) + remindersListAssetRecord.setValue( + UUID(1).uuidString.lowercased(), + forKey: "remindersListID", + at: now + ) + remindersListAssetRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + + await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord, remindersListRecord] + ) + + try { + try userDatabase.read { db in + let remindersListAsset = try #require(try RemindersListAsset.find(UUID(1)).fetchOne(db)) + #expect(remindersListAsset.coverImage == Data("image".utf8)) + } + }() + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 72ff6518..676a6ad8 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -324,5 +324,111 @@ extension BaseCloudKitTests { } }() } + + @Test func receiveNewRecordFromCloudKit_ChildBeforeParent() async throws { + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: UUID(1)) + ) + reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "remindersListID", at: now) + reminderRecord.userModificationDate = now + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: UUID(1)), + action: .none + ) + + await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(Reminder.recordName(for: UUID(1))).fetchOne(db) + ) + #expect(metadata.recordName == Reminder.recordName(for: UUID(1))) + #expect(metadata.parentRecordName == RemindersList.recordName(for: UUID(1))) + let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) + #expect(reminder == Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1))) + } + }() + + try await withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.title = "Buy milk" }.execute(db) + } + + await syncEngine.processBatch() + } + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 1, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 1, + 🗓️: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try userDatabase.read { db in + let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) + #expect(reminder == Reminder.init(id: UUID(1), title: "Buy milk", remindersListID: UUID(1))) + } + }() + } } } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 5178b925..7ea84f48 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -8,7 +8,10 @@ import Testing @Suite( .snapshots(record: .missing), - .dependency(\.date.now, Date(timeIntervalSince1970: 0)) + .dependencies { + $0.date.now = Date(timeIntervalSince1970: 0) + $0.dataManager = InMemoryDataManager() + } ) class BaseCloudKitTests: @unchecked Sendable { let userDatabase: UserDatabase @@ -47,6 +50,7 @@ class BaseCloudKitTests: @unchecked Sendable { tables: [ Reminder.self, RemindersList.self, + RemindersListAsset.self, Tag.self, ReminderTag.self, Parent.self, diff --git a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift index e3e34478..0b471bbd 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift @@ -43,6 +43,9 @@ : rhs return (baseLHS, lhsHasPrefix ? 1 : 0) < (baseRHS, rhsHasPrefix ? 1 : 0) } + let nonEncryptedKeys = Set(allKeys()) + .subtracting(encryptedValues.allKeys()) + .subtracting(["_recordChangeTag"]) return Mirror( self, children: [ @@ -62,12 +65,37 @@ $0, self.encryptedValues[$0] as Any ) + } + nonEncryptedKeys.map { + ( + $0, + self[$0] as Any + ) }, displayStyle: .struct ) } } +extension CKAsset: @retroactive CustomDumpReflectable { + public var customDumpMirror: Mirror { + @Dependency(\.dataManager) var dataManager + return Mirror( + self, + children: [ + ( + "fileURL", + fileURL as Any + ), + ( + "dataString", + String(decoding: fileURL.flatMap { try? dataManager.load($0) } ?? Data(), as: UTF8.self) + ) + ], + displayStyle: .struct + ) + } +} + extension CKRecord.Reference: @retroactive CustomDumpReflectable { public var customDumpMirror: Mirror { return Mirror( diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index be878d51..a5fe2e2f 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -11,6 +11,11 @@ import SharingGRDB let id: UUID var title = "" } +@Table struct RemindersListAsset: Equatable, Identifiable { + let id: UUID + var coverImage: Data? + var remindersListID: RemindersList.ID +} @Table struct RemindersListPrivate: Equatable, Identifiable { let id: UUID var position = 0 @@ -64,7 +69,17 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ CREATE TABLE "remindersLists" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "remindersListAssets" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "coverImage" BLOB NOT NULL, + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -73,7 +88,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ CREATE TABLE "remindersListPrivates" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "position" INTEGER NOT NULL DEFAULT 0, + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ @@ -83,8 +98,8 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "isCompleted" INTEGER NOT NULL DEFAULT 0, - "title" TEXT NOT NULL DEFAULT '', + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE @@ -96,7 +111,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ CREATE TABLE "tags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ ) @@ -142,7 +157,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ CREATE TABLE "localUsers" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "name" TEXT NOT NULL DEFAULT '', + "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE ) STRICT """ From 324a532f3af9f47a8296756a578fbbad1dead034 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 10:30:51 -0700 Subject: [PATCH 316/581] update snaps --- .../CloudKitTests/CloudKitTests.swift | 34 +++--- .../CloudKitTests/RecordTypeTests.swift | 38 ++++--- .../CloudKitTests/TriggerTests.swift | 102 ++++++++++++------ .../Internal/BaseCloudKitTests.swift | 2 +- 4 files changed, 117 insertions(+), 59 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 7cb34415..865fc566 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -25,43 +25,53 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "remindersLists" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ ), [1]: RecordType( + tableName: "remindersListAssets", + schema: """ + CREATE TABLE "remindersListAssets" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "coverImage" BLOB NOT NULL, + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ), + [2]: RecordType( tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "position" INTEGER NOT NULL DEFAULT 0, + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ), - [2]: RecordType( + [3]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "isCompleted" INTEGER NOT NULL DEFAULT 0, - "title" TEXT NOT NULL DEFAULT '', + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ ), - [3]: RecordType( + [4]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ ), - [4]: RecordType( + [5]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( @@ -71,7 +81,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [5]: RecordType( + [6]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( @@ -79,7 +89,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [6]: RecordType( + [7]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( @@ -88,7 +98,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [7]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -97,7 +107,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [8]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 69707f0b..26b7ee91 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -22,43 +22,53 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "remindersLists" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ ), [1]: RecordType( + tableName: "remindersListAssets", + schema: """ + CREATE TABLE "remindersListAssets" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "coverImage" BLOB NOT NULL, + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ), + [2]: RecordType( tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "position" INTEGER NOT NULL DEFAULT 0, + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ), - [2]: RecordType( + [3]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "isCompleted" INTEGER NOT NULL DEFAULT 0, - "title" TEXT NOT NULL DEFAULT '', + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ ), - [3]: RecordType( + [4]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ ), - [4]: RecordType( + [5]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( @@ -68,7 +78,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [5]: RecordType( + [6]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( @@ -76,7 +86,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [6]: RecordType( + [7]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( @@ -85,7 +95,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [7]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -94,7 +104,7 @@ extension BaseCloudKitTests { ) STRICT """ ), - [8]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( @@ -158,8 +168,8 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "isCompleted" INTEGER NOT NULL DEFAULT 0, - "title" TEXT NOT NULL DEFAULT '', + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 0fdeae48..d765717a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -98,6 +98,14 @@ extension BaseCloudKitTests { END """, [9]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets" + AFTER DELETE ON "remindersListAssets" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListAssets'); + END + """, + [10]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" AFTER DELETE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -105,7 +113,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListPrivates'); END """, - [10]: """ + [11]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -113,7 +121,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); END """, - [11]: """ + [12]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags" AFTER DELETE ON "tags" FOR EACH ROW BEGIN @@ -121,7 +129,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'tags'); END """, - [12]: """ + [13]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" AFTER INSERT ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -132,7 +140,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [13]: """ + [14]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -143,7 +151,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [14]: """ + [15]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -154,7 +162,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [15]: """ + [16]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN @@ -165,7 +173,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [16]: """ + [17]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN @@ -176,7 +184,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [17]: """ + [18]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN @@ -187,7 +195,18 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [18]: """ + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" + AFTER INSERT ON "remindersListAssets" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName") + SELECT 'remindersListAssets', "new"."id" || ':' || 'remindersListAssets', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [20]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -198,7 +217,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [19]: """ + [21]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -209,7 +228,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [20]: """ + [22]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" AFTER INSERT ON "tags" FOR EACH ROW BEGIN @@ -220,7 +239,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [21]: """ + [23]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" AFTER UPDATE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -231,7 +250,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [22]: """ + [24]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -242,7 +261,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [23]: """ + [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -253,7 +272,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [24]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -264,7 +283,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [25]: """ + [27]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -275,7 +294,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [26]: """ + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -286,7 +305,18 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [27]: """ + [29]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" + AFTER UPDATE ON "remindersListAssets" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName") + SELECT 'remindersListAssets', "new"."id" || ':' || 'remindersListAssets', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [30]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -297,7 +327,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [28]: """ + [31]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -308,7 +338,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [29]: """ + [32]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN @@ -319,7 +349,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [30]: """ + [33]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" BEFORE DELETE ON "parents" FOR EACH ROW BEGIN @@ -328,7 +358,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [31]: """ + [34]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" BEFORE UPDATE ON "parents" FOR EACH ROW BEGIN @@ -337,7 +367,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [32]: """ + [35]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -346,7 +376,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [33]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -355,7 +385,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [34]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -364,7 +394,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [35]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -373,7 +403,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [36]: """ + [39]: """ CREATE TRIGGER "sqlitedata_icloud_localUsers_belongsTo_localUsers_onDeleteCascade" AFTER DELETE ON "localUsers" FOR EACH ROW BEGIN @@ -381,7 +411,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [37]: """ + [40]: """ CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_reminders_onDeleteCascade" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN @@ -389,7 +419,7 @@ extension BaseCloudKitTests { WHERE "reminderID" = "old"."id"; END """, - [38]: """ + [41]: """ CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_tags_onDeleteCascade" AFTER DELETE ON "tags" FOR EACH ROW BEGIN @@ -397,7 +427,15 @@ extension BaseCloudKitTests { WHERE "tagID" = "old"."id"; END """, - [39]: """ + [42]: """ + CREATE TRIGGER "sqlitedata_icloud_remindersListAssets_belongsTo_remindersLists_onDeleteCascade" + AFTER DELETE ON "remindersLists" + FOR EACH ROW BEGIN + DELETE FROM "remindersListAssets" + WHERE "remindersListID" = "old"."id"; + END + """, + [43]: """ CREATE TRIGGER "sqlitedata_icloud_remindersListPrivates_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -405,7 +443,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [40]: """ + [44]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -413,7 +451,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [41]: """ + [45]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 7ea84f48..58f648a0 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,7 @@ import SnapshotTesting import Testing @Suite( - .snapshots(record: .missing), + .snapshots(record: .failed), .dependencies { $0.date.now = Date(timeIntervalSince1970: 0) $0.dataManager = InMemoryDataManager() From bb8164bd137d42f09b68b0d4a8c7e21735384df1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 11:07:12 -0700 Subject: [PATCH 317/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 12 ++++++++++-- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 11 +++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 9c210206..36aee4e5 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -256,7 +256,8 @@ extension CKRecord { } else { didSet = false } - let lastKnownValue = other.encryptedValues[key] + // TODO: handle assets here + let lastKnownValue: (any CKRecordValueProtocol)? = other.encryptedValues[key] var localValue: (any CKRecordValueProtocol)? { let value = Value(queryOutput: row[keyPath: column.keyPath]) switch value.queryBinding { @@ -272,6 +273,9 @@ extension CKRecord { return nil } } + if !_isEqual(localValue, lastKnownValue) { + print(localValue, lastKnownValue) + } if didSet || !_isEqual(localValue, lastKnownValue) { columnNames.removeAll(where: { $0 == key }) } @@ -319,8 +323,12 @@ extension CKRecord { } #endif - +// TODO: test private func _isEqual(_ lhs: Any?, _ rhs: Any?) -> Bool { + guard let lhs, let rhs + else { + return lhs == nil && rhs == nil + } guard let lhs = lhs as? any Equatable else { return false } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 76e0d292..36f3ef7f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -861,6 +861,17 @@ } } + // device A (title: A) + // device B (notes: B) + + /* + + --->(device A saves)------>(batch goes out)----->(receive: fails)----> + + ------>(device B saves)------>(batch goes out)----->(receive: fails) + + */ + switch failedRecordSave.error.code { case .serverRecordChanged: guard let serverRecord = failedRecordSave.error.serverRecord else { continue } From 36c561a4a946cf7b402dba89037f4640760ee18d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 7 Jul 2025 11:13:24 -0700 Subject: [PATCH 318/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 36aee4e5..3e8310ad 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -126,6 +126,15 @@ extension CKRecordKeyValueSetting { } } +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +extension CKAsset { + convenience init(data: some DataProtocol) { + @Dependency(\.dataManager) var dataManager + let hash = SHA256.hash(data: data).compactMap { String(format: "%02hhx", $0) }.joined() + self.init(fileURL: dataManager.temporaryDirectory.appendingPathComponent(hash)) + } +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { @discardableResult @@ -152,13 +161,11 @@ extension CKRecord { @Dependency(\.dataManager) var dataManager guard encryptedValues[at: key] < userModificationDate else { return false } - let hash = SHA256.hash(data: newValue).compactMap { String(format: "%02hhx", $0) }.joined() - let blobURL = dataManager.temporaryDirectory.appendingPathComponent(hash) - let asset = CKAsset(fileURL: blobURL) - guard (self[key] as? CKAsset)?.fileURL != blobURL + let asset = CKAsset(data: newValue) + guard let fileURL = asset.fileURL, (self[key] as? CKAsset)?.fileURL != fileURL else { return false } withErrorReporting { - try dataManager.save(Data(newValue), to: blobURL) + try dataManager.save(Data(newValue), to: fileURL) } self[key] = asset encryptedValues[at: key] = userModificationDate @@ -218,7 +225,6 @@ extension CKRecord { case .null: removeValue(forKey: column.name, at: userModificationDate) case .text(let value): - // self t=1, t=2 setValue(value, forKey: column.name, at: userModificationDate) case .uuid(let value): setValue( @@ -256,12 +262,11 @@ extension CKRecord { } else { didSet = false } - // TODO: handle assets here let lastKnownValue: (any CKRecordValueProtocol)? = other.encryptedValues[key] var localValue: (any CKRecordValueProtocol)? { let value = Value(queryOutput: row[keyPath: column.keyPath]) switch value.queryBinding { - case .blob(let value): return value + case .blob(let value): return CKAsset(data: value) case .double(let value): return value case .date(let value): return value case .int(let value): return value @@ -273,9 +278,6 @@ extension CKRecord { return nil } } - if !_isEqual(localValue, lastKnownValue) { - print(localValue, lastKnownValue) - } if didSet || !_isEqual(localValue, lastKnownValue) { columnNames.removeAll(where: { $0 == key }) } From a6284e6d5c16b51517b312ab6bde7e24239698a7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 7 Jul 2025 11:26:45 -0700 Subject: [PATCH 319/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 56 ++++++++----------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 3e8310ad..09df6b27 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -127,11 +127,11 @@ extension CKRecordKeyValueSetting { } @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -extension CKAsset { - convenience init(data: some DataProtocol) { +extension URL { + init(hash data: some DataProtocol) { @Dependency(\.dataManager) var dataManager let hash = SHA256.hash(data: data).compactMap { String(format: "%02hhx", $0) }.joined() - self.init(fileURL: dataManager.temporaryDirectory.appendingPathComponent(hash)) + self = dataManager.temporaryDirectory.appendingPathComponent(hash) } } @@ -161,7 +161,8 @@ extension CKRecord { @Dependency(\.dataManager) var dataManager guard encryptedValues[at: key] < userModificationDate else { return false } - let asset = CKAsset(data: newValue) + + let asset = CKAsset(fileURL: URL(hash: newValue)) guard let fileURL = asset.fileURL, (self[key] as? CKAsset)?.fileURL != fileURL else { return false } withErrorReporting { @@ -262,23 +263,29 @@ extension CKRecord { } else { didSet = false } - let lastKnownValue: (any CKRecordValueProtocol)? = other.encryptedValues[key] - var localValue: (any CKRecordValueProtocol)? { - let value = Value(queryOutput: row[keyPath: column.keyPath]) - switch value.queryBinding { - case .blob(let value): return CKAsset(data: value) - case .double(let value): return value - case .date(let value): return value - case .int(let value): return value - case .null: return nil - case .text(let value): return value - case .uuid(let value): return value.uuidString.lowercased() + /// The row value has been modified more recently than the last known record. + var isRowValueModified: Bool { + switch Value(queryOutput: row[keyPath: column.keyPath]).queryBinding { + case .blob(let value): + return (other[key] as? CKAsset)?.fileURL != URL(hash: value) + case .double(let value): + return other.encryptedValues[key] != value + case .date(let value): + return other.encryptedValues[key] != value + case .int(let value): + return other.encryptedValues[key] != value + case .null: + return other.encryptedValues[key] != nil + case .text(let value): + return other.encryptedValues[key] != value + case .uuid(let value): + return other.encryptedValues[key] != value.uuidString.lowercased() case .invalid(let error): reportIssue(error) - return nil + return false } } - if didSet || !_isEqual(localValue, lastKnownValue) { + if didSet || isRowValueModified { columnNames.removeAll(where: { $0 == key }) } } @@ -324,18 +331,3 @@ extension CKRecord { } } #endif - -// TODO: test -private func _isEqual(_ lhs: Any?, _ rhs: Any?) -> Bool { - guard let lhs, let rhs - else { - return lhs == nil && rhs == nil - } - guard let lhs = lhs as? any Equatable - else { return false } - - func open(_ lhs: S) -> Bool { - (rhs as? S).map { lhs == $0 } ?? false - } - return open(lhs) -} From 3c94c3e17d0a538ee5b01afcff4e0632a4d55d8b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 11:29:28 -0700 Subject: [PATCH 320/581] test for nil checking --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 11 - .../CloudKitTests/CloudKitTests.swift | 4 +- .../CloudKitTests/MergeConflictTests.swift | 194 +++++++++++++----- Tests/SharingGRDBTests/Internal/Schema.swift | 4 + 4 files changed, 151 insertions(+), 62 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 36f3ef7f..76e0d292 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -861,17 +861,6 @@ } } - // device A (title: A) - // device B (notes: B) - - /* - - --->(device A saves)------>(batch goes out)----->(receive: fails)----> - - ------>(device B saves)------>(batch goes out)----->(receive: fails) - - */ - switch failedRecordSave.error.code { case .serverRecordChanged: guard let serverRecord = failedRecordSave.error.serverRecord else { continue } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 865fc566..f0a58a66 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -466,9 +466,9 @@ extension BaseCloudKitTests { record.encryptedValues["title"] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) record.userModificationDate = serverModificationDate - // NB: Manually setting '_recordChangeTag' simulates another devices saving a record. + // NB: Manually setting '_recordChangeTag' simulates another device saving a record. record._recordChangeTag = UUID().uuidString - _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) + await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( try { try userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 7f1ddcb5..65d8a977 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -385,10 +385,10 @@ extension BaseCloudKitTests { """ } -// await syncEngine.processBatch() -// await modificationCallback() -// -// assertInlineSnapshot(of: syncEngine.container, as: .customDump) + // await syncEngine.processBatch() + // await modificationCallback() + // + // assertInlineSnapshot(of: syncEngine.container, as: .customDump) } @Test func bar() async throws { @@ -419,7 +419,7 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try #expect( Reminder.find(UUID(1)).fetchOne(db) - == Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + == Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) ) } @@ -464,7 +464,7 @@ extension BaseCloudKitTests { ) """ } - + await syncEngine.processBatch() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { @@ -577,49 +577,145 @@ extension BaseCloudKitTests { """ } -// await syncEngine.processBatch() -// -// assertInlineSnapshot(of: syncEngine.container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [ -// [0]: CKRecord( -// recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), -// recordType: "reminders", -// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), -// share: nil, -// id: "00000000-0000-0000-0000-000000000001", -// id🗓️: 0, -// isCompleted: 0, -// isCompleted🗓️: 0, -// remindersListID: "00000000-0000-0000-0000-000000000001", -// remindersListID🗓️: 0, -// title: "Buy milk", -// title🗓️: 30, -// 🗓️: 30 -// ), -// [1]: CKRecord( -// recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), -// recordType: "remindersLists", -// parent: nil, -// share: nil, -// id: "00000000-0000-0000-0000-000000000001", -// id🗓️: 0, -// title: "", -// title🗓️: 0, -// 🗓️: 0 -// ) -// ] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } + // await syncEngine.processBatch() + // + // assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + // """ + // MockCloudContainer( + // privateCloudDatabase: MockCloudDatabase( + // databaseScope: .private, + // storage: [ + // [0]: CKRecord( + // recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + // recordType: "reminders", + // parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + // share: nil, + // id: "00000000-0000-0000-0000-000000000001", + // id🗓️: 0, + // isCompleted: 0, + // isCompleted🗓️: 0, + // remindersListID: "00000000-0000-0000-0000-000000000001", + // remindersListID🗓️: 0, + // title: "Buy milk", + // title🗓️: 30, + // 🗓️: 30 + // ), + // [1]: CKRecord( + // recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + // recordType: "remindersLists", + // parent: nil, + // share: nil, + // id: "00000000-0000-0000-0000-000000000001", + // id🗓️: 0, + // title: "", + // title🗓️: 0, + // 🗓️: 0 + // ) + // ] + // ), + // sharedCloudDatabase: MockCloudDatabase( + // databaseScope: .shared, + // storage: [] + // ) + // ) + // """ + // } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func mergeWithNullableFields() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + Reminder(id: UUID(1), remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: UUID(1)) + ) + reminderRecord.setValue( + now.addingTimeInterval(30), + forKey: "dueDate", + at: now.addingTimeInterval(1) + ) + reminderRecord.userModificationDate = now.addingTimeInterval(1) + let modificationsFinished = { + syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + }() + + try withDependencies { + $0.date.now.addTimeInterval(2) + } operation: { + try userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.priority = 3 }.execute(db) + } + } + + await modificationsFinished() + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + dueDate: Date(1970-01-01T00:00:30.000Z), + dueDate🗓️: 1, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + priority: 3, + priority🗓️: 2, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 2 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "Personal", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try userDatabase.read { db in + let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) + #expect( + reminder + == Reminder( + id: UUID(1), + dueDate: Date(timeIntervalSince1970: 30), + priority: 3, + remindersListID: UUID(1) + ) + ) + } + }() } } } diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index a5fe2e2f..05994798 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -3,7 +3,9 @@ import SharingGRDB @Table struct Reminder: Equatable, Identifiable { let id: UUID + var dueDate: Date? var isCompleted = false + var priority: Int? var title = "" var remindersListID: RemindersList.ID } @@ -98,7 +100,9 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "remindersListID" TEXT NOT NULL, From dafe3a30e6331d69f33ccc1829d1df1b89bcf6be Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 11:31:31 -0700 Subject: [PATCH 321/581] snapshots --- Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift | 2 ++ Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index f0a58a66..f9ef5570 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -54,7 +54,9 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "remindersListID" TEXT NOT NULL, diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 26b7ee91..5e8d41f7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -51,7 +51,9 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "remindersListID" TEXT NOT NULL, @@ -168,7 +170,9 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, From 114f55271e89ae7b14436a3faf3b3189de6063f6 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 7 Jul 2025 11:39:17 -0700 Subject: [PATCH 322/581] wip --- .../SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift | 4 ++-- Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 09df6b27..d7e51d7f 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -15,7 +15,7 @@ extension _CKRecord where Self == CKShare { } extension Optional where Wrapped: CKRecord { - package typealias AllFieldsRepresentation = _AllFieldsRepresentation? + typealias AllFieldsRepresentation = _AllFieldsRepresentation? public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? } @@ -54,7 +54,7 @@ public struct _SystemFieldsRepresentation: QueryBindable, Quer private struct DecodingError: Error {} } -public struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { +struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { public let queryOutput: Record public var queryBinding: QueryBinding { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 95953d0c..ceb30cc5 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -125,7 +125,7 @@ extension SyncMetadata.TableColumns { SQLQueryExpression("substr(\(parentRecordName), 38)") } - package var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< + var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< SyncMetadata, CKRecord?.AllFieldsRepresentation > { From a360e2610957bad5d52e40e7c2fb3c41bba5c9a7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 7 Jul 2025 11:46:03 -0700 Subject: [PATCH 323/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 76e0d292..c5d11898 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1022,10 +1022,7 @@ try SQLQueryExpression(query).execute(db) try SyncMetadata .find(recordName) - .update { - $0.setLastKnownServerRecord(serverRecord) - $0.userModificationDate = serverRecord.userModificationDate - } + .update { $0.setLastKnownServerRecord(serverRecord) } .execute(db) } } @@ -1045,10 +1042,7 @@ try userDatabase.write { db in try SyncMetadata .find(recordName) - .update { - $0.setLastKnownServerRecord(record) - $0.userModificationDate = record.userModificationDate - } + .update { $0.setLastKnownServerRecord(record) } .execute(db) } } From db0f1ed74954a80f4472b6e1bcdba93f640c804c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 7 Jul 2025 11:47:48 -0700 Subject: [PATCH 324/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c5d11898..92a098dd 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1346,16 +1346,6 @@ } } - // TODO: Remove when available on 'main' - extension QueryFragment.StringInterpolation { - public mutating func appendInterpolation( - _ queryOutput: QueryValue.QueryOutput, - as representableType: QueryValue.Type - ) { - appendInterpolation(QueryValue(queryOutput: queryOutput)) - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Updates { mutating func setLastKnownServerRecord(_ lastKnownServerRecord: CKRecord?) { From 951120919efe3493c454423c7a4843f36b7afdb9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 7 Jul 2025 11:48:24 -0700 Subject: [PATCH 325/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index ceb30cc5..1092932a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -36,13 +36,10 @@ public struct SyncMetadata: Hashable, Sendable { /// ``` public var parentRecordName: RecordName? - // TODO: lastKnownSystemFields /// The last known `CKRecord` received from the server. // @Column(as: CKRecord?.DataRepresentation.self) public var lastKnownServerRecord: CKRecord? - // TODO: _lastKnownAllFields - /// The `CKShare` associated with this record, if it is shared. // @Column(as: CKShare?.SystemFieldsRepresentation.self) public var share: CKShare? From 18192cea122d47e451c39665be03868a35979aac Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 11:58:25 -0700 Subject: [PATCH 326/581] clean up --- .../CloudKit/CloudKit+StructuredQueries.swift | 12 ++-- .../CloudKit/SyncMetadata.swift | 2 +- .../FetchRecordZoneChangesTests.swift | 65 ++++--------------- 3 files changed, 18 insertions(+), 61 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index d7e51d7f..e4d1b33d 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -15,7 +15,7 @@ extension _CKRecord where Self == CKShare { } extension Optional where Wrapped: CKRecord { - typealias AllFieldsRepresentation = _AllFieldsRepresentation? + package typealias AllFieldsRepresentation = _AllFieldsRepresentation? public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? } @@ -54,10 +54,10 @@ public struct _SystemFieldsRepresentation: QueryBindable, Quer private struct DecodingError: Error {} } -struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { - public let queryOutput: Record +package struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { + package let queryOutput: Record - public var queryBinding: QueryBinding { + package var queryBinding: QueryBinding { let archiver = NSKeyedArchiver(requiringSecureCoding: true) queryOutput.encode(with: archiver) if isTesting { @@ -66,11 +66,11 @@ struct _AllFieldsRepresentation: QueryBindable, QueryRepresent return archiver.encodedData.queryBinding } - public init(queryOutput: Record) { + package init(queryOutput: Record) { self.queryOutput = queryOutput } - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { guard let data = try Data?(decoder: &decoder) else { throw QueryDecodingError.missingRequiredColumn } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 1092932a..f3355ae1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -122,7 +122,7 @@ extension SyncMetadata.TableColumns { SQLQueryExpression("substr(\(parentRecordName), 38)") } - var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< + package var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< SyncMetadata, CKRecord?.AllFieldsRepresentation > { diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 676a6ad8..954c8bc8 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -9,7 +9,7 @@ import Testing extension BaseCloudKitTests { @MainActor - @Suite(.printTimestamps) + @Suite final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { @Dependency(\.date.now) var now @@ -44,16 +44,10 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, isCompleted: 0, - isCompleted🗓️: 0, newField: "Hello world! 🌎🌎🌎", - newField🗓️: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - remindersListID🗓️: 0, - title: "Get milk", - title🗓️: 0, - 🗓️: 0 + title: "Get milk" ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -61,10 +55,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, - title: "Personal", - title🗓️: 0, - 🗓️: 0 + title: "Personal" ) ] """ @@ -96,16 +87,10 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, isCompleted: 1, - isCompleted🗓️: 1, newField: "Hello world! 🌎🌎🌎", - newField🗓️: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - remindersListID🗓️: 0, - title: "Get milk", - title🗓️: 0, - 🗓️: 1 + title: "Get milk" ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -113,10 +98,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, - title: "Personal", - title🗓️: 0, - 🗓️: 0 + title: "Personal" ) ] """ @@ -160,14 +142,9 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, isCompleted: 0, - isCompleted🗓️: 0, remindersListID: "00000000-0000-0000-0000-000000000002", - remindersListID🗓️: 1, - title: "Get milk", - title🗓️: 0, - 🗓️: 0 + title: "Get milk" ) """ } @@ -200,14 +177,9 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, isCompleted: 0, - isCompleted🗓️: 0, remindersListID: "00000000-0000-0000-0000-000000000002", - remindersListID🗓️: 1, - title: "Get milk", - title🗓️: 0, - 🗓️: 0 + title: "Get milk" ) """ } @@ -254,10 +226,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, - title: "Personal", - title🗓️: 0, - 🗓️: 0 + title: "Personal" ) ] ), @@ -302,10 +271,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, - title: "My stuff", - title🗓️: 1, - 🗓️: 1 + title: "My stuff" ) ] ), @@ -353,12 +319,8 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, remindersListID: "00000000-0000-0000-0000-000000000001", - remindersListID🗓️: 0, - title: "Get milk", - title🗓️: 0, - 🗓️: 0 + title: "Get milk" ) ] ), @@ -404,14 +366,9 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, id: "00000000-0000-0000-0000-000000000001", - id🗓️: 0, isCompleted: 0, - isCompleted🗓️: 1, remindersListID: "00000000-0000-0000-0000-000000000001", - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 1, - 🗓️: 1 + title: "Buy milk" ) ] ), From abf6b6c4011c6e711941ca97bd2d66fc22d03b63 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 12:04:16 -0700 Subject: [PATCH 327/581] clean up tests --- .../CloudKitTests/MergeConflictTests.swift | 122 ++++++++++-------- 1 file changed, 71 insertions(+), 51 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 65d8a977..427e818c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -318,7 +318,7 @@ extension BaseCloudKitTests { } } - @Test func foo() async throws { + @Test func serverAndClientEditDifferentFields() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "") @@ -384,14 +384,9 @@ extension BaseCloudKitTests { ) """ } - - // await syncEngine.processBatch() - // await modificationCallback() - // - // assertInlineSnapshot(of: syncEngine.container, as: .customDump) } - @Test func bar() async throws { + @Test func serverRecordEditedAfterClientButProcessedBeforeClient() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "") @@ -510,7 +505,7 @@ extension BaseCloudKitTests { } } - @Test func baz() async throws { + @Test func serverRecordEditedAndProcessedBeforeClient() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: UUID(1), title: "") @@ -576,50 +571,75 @@ extension BaseCloudKitTests { ) """ } + } + + @Test func serverRecordEditedBeforeClientButProcessedAfterClient() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "") + Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + } + } + await syncEngine.processBatch() + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let userModificationDate = now.addingTimeInterval(30) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + record.userModificationDate = userModificationDate + let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() + + try await withDependencies { + $0.date.now = now.addingTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(UUID(1)).update { $0.title = "Get milk" }.execute(db) + } + } + await syncEngine.processBatch() + await modificationCallback() + await syncEngine.processBatch() - // await syncEngine.processBatch() - // - // assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - // """ - // MockCloudContainer( - // privateCloudDatabase: MockCloudDatabase( - // databaseScope: .private, - // storage: [ - // [0]: CKRecord( - // recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - // recordType: "reminders", - // parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - // share: nil, - // id: "00000000-0000-0000-0000-000000000001", - // id🗓️: 0, - // isCompleted: 0, - // isCompleted🗓️: 0, - // remindersListID: "00000000-0000-0000-0000-000000000001", - // remindersListID🗓️: 0, - // title: "Buy milk", - // title🗓️: 30, - // 🗓️: 30 - // ), - // [1]: CKRecord( - // recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - // recordType: "remindersLists", - // parent: nil, - // share: nil, - // id: "00000000-0000-0000-0000-000000000001", - // id🗓️: 0, - // title: "", - // title🗓️: 0, - // 🗓️: 0 - // ) - // ] - // ), - // sharedCloudDatabase: MockCloudDatabase( - // databaseScope: .shared, - // storage: [] - // ) - // ) - // """ - // } + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From 3240fae8e897a351d6e6e8e763bacaac9482f606 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 12:19:10 -0700 Subject: [PATCH 328/581] wip --- .../Internal/CloudKitTestHelpers.swift | 27 ------------------ .../Internal/PrintTimestampsScope.swift | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 27 deletions(-) create mode 100644 Tests/SharingGRDBTests/Internal/PrintTimestampsScope.swift diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 7bccd8e7..5f30f80d 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -639,10 +639,6 @@ extension SyncEngine { ), syncEngine: syncEngine ) - - if !syncEngine.state.pendingRecordZoneChanges.isEmpty { - // fatalError("Should we add the option to immediately process any enqueued changes?") - } } private func syncEngine(for scope: CKDatabase.Scope) -> MockSyncEngine { @@ -658,26 +654,3 @@ extension SyncEngine { } } } - -struct _PrintTimestampsScope: SuiteTrait, TestScoping, TestTrait { - let printTimestamps: Bool - init(_ printTimestamps: Bool = true) { - self.printTimestamps = printTimestamps - } - func provideScope( - for test: Test, - testCase: Test.Case?, - performing function: @Sendable () async throws -> Void - ) async throws { - try await CKRecord.$printTimestamps.withValue(true) { - try await function() - } - } -} - -extension Trait where Self == _PrintTimestampsScope { - static var printTimestamps: Self { .init() } - static func printTimestamps(_ printTimestamps: Bool) -> Self { - .init(printTimestamps) - } -} diff --git a/Tests/SharingGRDBTests/Internal/PrintTimestampsScope.swift b/Tests/SharingGRDBTests/Internal/PrintTimestampsScope.swift new file mode 100644 index 00000000..f300124c --- /dev/null +++ b/Tests/SharingGRDBTests/Internal/PrintTimestampsScope.swift @@ -0,0 +1,28 @@ +#if canImport(CloudKit) + import CloudKit + import Testing + + struct _PrintTimestampsScope: SuiteTrait, TestScoping, TestTrait { + let printTimestamps: Bool + init(_ printTimestamps: Bool = true) { + self.printTimestamps = printTimestamps + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await CKRecord.$printTimestamps.withValue(true) { + try await function() + } + } + } + + extension Trait where Self == _PrintTimestampsScope { + static var printTimestamps: Self { .init() } + static func printTimestamps(_ printTimestamps: Bool) -> Self { + .init(printTimestamps) + } + } +#endif From 23fafb4b082e2fdada71ab5b351897379b362e02 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 12:25:43 -0700 Subject: [PATCH 329/581] umd --- .../CloudKit/CloudKit+StructuredQueries.swift | 6 +++++- Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift | 2 -- .../CloudKitTests/FetchRecordZoneChangesTests.swift | 2 -- .../CloudKitTests/MergeConflictTests.swift | 7 ------- Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift | 3 --- .../CloudKitTests/SyncEngineSetUpTests.swift | 3 --- Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift | 2 +- 7 files changed, 6 insertions(+), 19 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index e4d1b33d..08433563 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -149,6 +149,7 @@ extension CKRecord { else { return false } encryptedValues[key] = newValue encryptedValues[at: key] = userModificationDate + self.userModificationDate = userModificationDate return true } @@ -170,6 +171,7 @@ extension CKRecord { } self[key] = asset encryptedValues[at: key] = userModificationDate + self.userModificationDate = userModificationDate return true } @@ -186,6 +188,7 @@ extension CKRecord { else { return false } self[key] = newValue encryptedValues[at: key] = userModificationDate + self.userModificationDate = userModificationDate return true } @@ -199,17 +202,18 @@ extension CKRecord { if encryptedValues[key] != nil { encryptedValues[key] = nil encryptedValues[at: key] = userModificationDate + self.userModificationDate = userModificationDate return true } else if self[key] != nil { self[key] = nil encryptedValues[at: key] = userModificationDate + self.userModificationDate = userModificationDate return true } return false } package func update(with row: T, userModificationDate: Date) { - self.userModificationDate = userModificationDate for column in T.TableColumns.allColumns { func open(_ column: some TableColumnExpression) { let column = column as! any TableColumnExpression diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index f9ef5570..23626e42 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -382,7 +382,6 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) let serverModificationDate = userModificationDate.addingTimeInterval(60) record.setValue("Work", forKey: "title", at: serverModificationDate) - record.userModificationDate = serverModificationDate _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( @@ -467,7 +466,6 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) record.encryptedValues["title"] = "Work" let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) - record.userModificationDate = serverModificationDate // NB: Manually setting '_recordChangeTag' simulates another device saving a record. record._recordChangeTag = UUID().uuidString await syncEngine.modifyRecords(scope: .private, saving: [record]) diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 954c8bc8..aa870cc3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -210,7 +210,6 @@ extension BaseCloudKitTests { ) remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) - remindersListRecord.userModificationDate = now await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) @@ -299,7 +298,6 @@ extension BaseCloudKitTests { reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) reminderRecord.setValue("Get milk", forKey: "title", at: now) reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "remindersListID", at: now) - reminderRecord.userModificationDate = now reminderRecord.parent = CKRecord.Reference( recordID: RemindersList.recordID(for: UUID(1)), action: .none diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 427e818c..1606386f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -65,7 +65,6 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) let userModificationDate = now.addingTimeInterval(60) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - record.userModificationDate = userModificationDate let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() try await withDependencies { @@ -218,7 +217,6 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - record.userModificationDate = userModificationDate let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() try await withDependencies { @@ -330,7 +328,6 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - record.userModificationDate = userModificationDate let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() try await withDependencies { @@ -398,7 +395,6 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) let userModificationDate = now.addingTimeInterval(60) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - record.userModificationDate = userModificationDate let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() try await withDependencies { @@ -517,7 +513,6 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - record.userModificationDate = userModificationDate let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() try await withDependencies { @@ -585,7 +580,6 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - record.userModificationDate = userModificationDate let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() try await withDependencies { @@ -660,7 +654,6 @@ extension BaseCloudKitTests { forKey: "dueDate", at: now.addingTimeInterval(1) ) - reminderRecord.userModificationDate = now.addingTimeInterval(1) let modificationsFinished = { syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) }() diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index b4376e29..4e1d25ff 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -73,7 +73,6 @@ extension BaseCloudKitTests { remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListRecord.setValue(false, forKey: "isCompleted", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) - remindersListRecord.userModificationDate = now await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]) @@ -137,7 +136,6 @@ extension BaseCloudKitTests { ) remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) - remindersListRecord.userModificationDate = now let reminderRecord = CKRecord( recordType: Reminder.tableName, recordID: Reminder.recordID(for: UUID(1), zoneID: externalZoneID) @@ -146,7 +144,6 @@ extension BaseCloudKitTests { reminderRecord.setValue(false, forKey: "isCompleted", at: now) reminderRecord.setValue("Get milk", forKey: "title", at: now) reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "remindersListID", at: now) - remindersListRecord.userModificationDate = now await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index 9f2127cc..d3171b00 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -32,19 +32,16 @@ extension BaseCloudKitTests { let personalListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: UUID(1)) ) - personalListRecord.userModificationDate = now personalListRecord.setValue(1, forKey: "position", at: now) let businessListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: UUID(2)) ) - businessListRecord.userModificationDate = now businessListRecord.setValue(2, forKey: "position", at: now) let reminderRecord = try syncEngine.private.database.record( for: Reminder.recordID(for: UUID(1)) ) - reminderRecord.userModificationDate = now reminderRecord.setValue(3, forKey: "position", at: now) await syncEngine.modifyRecords( diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 58f648a0..7ea84f48 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,7 @@ import SnapshotTesting import Testing @Suite( - .snapshots(record: .failed), + .snapshots(record: .missing), .dependencies { $0.date.now = Date(timeIntervalSince1970: 0) $0.dataManager = InMemoryDataManager() From 93b2ca932866a1701fd69a225451b914508e4753 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 7 Jul 2025 12:32:26 -0700 Subject: [PATCH 330/581] Update SyncEngine.swift --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 46f32409..af84d240 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1177,19 +1177,19 @@ let tableNames = Set(tables.map { $0.tableName }) try userDatabase.write { db in let triggers = try SQLQueryExpression( - """ - SELECT "name", "tbl_name", "sql" + """ + SELECT "name", "tbl_name", "sql" FROM "sqlite_master" WHERE "type" = 'trigger' - UNION - SELECT "name", "tbl_name", "sql" + UNION + SELECT "name", "tbl_name", "sql" FROM "sqlite_temp_master" WHERE "type" = 'trigger' - """, - as: (String, String, String).self + """, + as: (String, String, String).self ) - .fetchAll(db) - .filter { _, tableName, _ in tableNames.contains(tableName) } + .fetchAll(db) + .filter { _, tableName, _ in tableNames.contains(tableName) } let invalidTriggers = triggers.compactMap { name, _, sql in let isValid = sql From 2243aa81b29536f419889591fc8fba4716345363 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 7 Jul 2025 12:39:05 -0700 Subject: [PATCH 331/581] fix from merge --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift | 2 -- .../CloudKitTests/SyncEngineValidationTests.swift | 4 +++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0c80fbcf..0b7976d8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1229,7 +1229,7 @@ userDatabase: UserDatabase ) throws { let tableNames = Set(tables.map { $0.tableName }) - try userDatabase.write { db in + try userDatabase.read { db in let triggers = try SQLQueryExpression( """ SELECT "name", "tbl_name", "sql" diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index d3171b00..18793fa8 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -68,8 +68,6 @@ extension BaseCloudKitTests { let relaunchedSyncEngine = try await SyncEngine( container: syncEngine.container, - privateDatabase: syncEngine.container.privateCloudDatabase as! MockCloudDatabase, - sharedDatabase: syncEngine.container.sharedCloudDatabase as! MockCloudDatabase, userDatabase: self.userDatabase, metadatabaseURL: URL .metadatabase(containerIdentifier: syncEngine.container.containerIdentifier!), diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index d72871bc..3dce9f18 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -47,8 +47,9 @@ extension BaseCloudKitTests { ) .execute(db) } - let _ = try await SyncEngine.init( + let _ = try await SyncEngine( container: MockCloudContainer( + containerIdentifier: "deadbeef", privateCloudDatabase: MockCloudDatabase(databaseScope: .private), sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) ), @@ -106,6 +107,7 @@ extension BaseCloudKitTests { } let _ = try await SyncEngine.init( container: MockCloudContainer( + containerIdentifier: "deadbeef", privateCloudDatabase: MockCloudDatabase(databaseScope: .private), sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) ), From 00a30199902056291e535ef0a1f85473e4788ec3 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 7 Jul 2025 13:46:51 -0700 Subject: [PATCH 332/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index f3355ae1..f45420dc 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -37,7 +37,7 @@ public struct SyncMetadata: Hashable, Sendable { public var parentRecordName: RecordName? /// The last known `CKRecord` received from the server. - // @Column(as: CKRecord?.DataRepresentation.self) + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) public var lastKnownServerRecord: CKRecord? /// The `CKShare` associated with this record, if it is shared. From 2432e663841e8199e09fa55bc696dc3bcb094cdd Mon Sep 17 00:00:00 2001 From: Joshua Halickman Date: Wed, 9 Jul 2025 09:51:12 -0400 Subject: [PATCH 333/581] Exclude the CloudSharing UI from watchOS --- Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 549e7f99..c01d0ed3 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -106,8 +106,8 @@ extension SyncEngine { } } -#if canImport(UIKit) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +#if canImport(UIKit) && !os(watchOS) + @available(iOS 17, macOS 14, tvOS 17, *) public struct CloudSharingView: UIViewControllerRepresentable { let sharedRecord: SharedRecord let didFinish: (Result) -> Void @@ -149,7 +149,7 @@ extension SyncEngine { } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @available(iOS 17, macOS 14, tvOS 17, *) public final class CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { let share: CKShare let didFinish: (Result) -> Void From e57d5c252c8ecee07b576e99da187398258bfb8b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 9 Jul 2025 15:22:33 -0700 Subject: [PATCH 334/581] wip --- Sources/SharingGRDB/Exports.swift | 1 + .../CloudKit/Metadatabase.swift | 3 +- ....swift => RecordType+MacroExpansion.swift} | 73 +++++----- .../SharingGRDBCore/CloudKit/RecordType.swift | 8 ++ .../CloudKit/SQLiteSchema.swift | 39 ++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 128 ++++++++++-------- .../SharingGRDBCore/CloudKit/TableInfo.swift | 44 ++++++ .../CloudKitTests/SyncEngineSetUpTests.swift | 41 +++--- 8 files changed, 222 insertions(+), 115 deletions(-) rename Sources/SharingGRDBCore/CloudKit/{RecordTypeTable.swift => RecordType+MacroExpansion.swift} (55%) create mode 100644 Sources/SharingGRDBCore/CloudKit/RecordType.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/SQLiteSchema.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/TableInfo.swift diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift index 07a5c659..103bfe18 100644 --- a/Sources/SharingGRDB/Exports.swift +++ b/Sources/SharingGRDB/Exports.swift @@ -1,2 +1,3 @@ @_exported import SharingGRDBCore @_exported import StructuredQueriesGRDB + diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index d9e02863..41cfcaeb 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -60,7 +60,8 @@ func defaultMetadatabase( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( "tableName" TEXT NOT NULL PRIMARY KEY, - "schema" TEXT NOT NULL + "schema" TEXT NOT NULL, + "tableInfo" TEXT NOT NULL ) STRICT """ ) diff --git a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift b/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift similarity index 55% rename from Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift rename to Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift index 06f34e66..325d7190 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift @@ -1,83 +1,88 @@ -// @Table("\(String.sqliteDataCloudKitSchemaName)_recordTypes") -package struct RecordType: Hashable { - // @Column(primaryKey: true) - package let tableName: String - package let schema: String -} +import StructuredQueriesCore -// NB: This is generated by inlining the above macro applications. -extension RecordType: StructuredQueriesCore.Table, PrimaryKeyedTable { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore - .PrimaryKeyedTableDefinition - { +extension RecordType { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { public typealias QueryValue = RecordType - public let tableName = StructuredQueriesCore.TableColumn( - "tableName", - keyPath: \QueryValue.tableName - ) - public let schema = StructuredQueriesCore.TableColumn( - "schema", - keyPath: \QueryValue.schema - ) + public let tableName = StructuredQueriesCore.TableColumn("tableName", keyPath: \QueryValue.tableName) + public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) + public let tableInfo = StructuredQueriesCore.TableColumn("tableInfo", keyPath: \QueryValue.tableInfo) public var primaryKey: StructuredQueriesCore.TableColumn { self.tableName } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.tableName, QueryValue.columns.schema] + [QueryValue.columns.tableName, QueryValue.columns.schema, QueryValue.columns.tableInfo] } } + public struct Draft: StructuredQueriesCore.TableDraft { public typealias PrimaryTable = RecordType - let tableName: String? - let schema: String + package let tableName: String? + package let schema: String + package let tableInfo: [TableInfo] public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = RecordType.Draft - public let tableName = StructuredQueriesCore.TableColumn( - "tableName", - keyPath: \QueryValue.tableName - ) - public let schema = StructuredQueriesCore.TableColumn( - "schema", - keyPath: \QueryValue.schema - ) + public typealias QueryValue = Draft + public let tableName = StructuredQueriesCore.TableColumn("tableName", keyPath: \QueryValue.tableName) + public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) + public let tableInfo = StructuredQueriesCore.TableColumn("tableInfo", keyPath: \QueryValue.tableInfo) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.tableName, QueryValue.columns.schema] + [QueryValue.columns.tableName, QueryValue.columns.schema, QueryValue.columns.tableInfo] } } public static let columns = TableColumns() + public static let tableName = RecordType.tableName + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { self.tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) + let tableInfo = try decoder.decode([TableInfo].JSONRepresentation.self) guard let schema else { throw QueryDecodingError.missingRequiredColumn } + guard let tableInfo else { + throw QueryDecodingError.missingRequiredColumn + } self.schema = schema + self.tableInfo = tableInfo } + public init(_ other: RecordType) { self.tableName = other.tableName self.schema = other.schema + self.tableInfo = other.tableInfo } public init( tableName: String? = nil, - schema: String + schema: String, + tableInfo: [TableInfo] ) { self.tableName = tableName self.schema = schema + self.tableInfo = tableInfo } } +} + +extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public static let columns = TableColumns() - public static let tableName = "\(String.sqliteDataCloudKitSchemaName)_recordTypes" + public static let tableName = "sqlitedata_icloud_recordTypes" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) + let tableInfo = try decoder.decode([TableInfo].JSONRepresentation.self) guard let tableName else { throw QueryDecodingError.missingRequiredColumn } guard let schema else { throw QueryDecodingError.missingRequiredColumn } + guard let tableInfo else { + throw QueryDecodingError.missingRequiredColumn + } self.tableName = tableName self.schema = schema + self.tableInfo = tableInfo } } + + diff --git a/Sources/SharingGRDBCore/CloudKit/RecordType.swift b/Sources/SharingGRDBCore/CloudKit/RecordType.swift new file mode 100644 index 00000000..8e035429 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/RecordType.swift @@ -0,0 +1,8 @@ +// @Table("\(String.sqliteDataCloudKitSchemaName)_recordTypes") +package struct RecordType: Hashable { + // @Column(primaryKey: true) + package let tableName: String + package let schema: String + // @Column(as: [TableInfo].JSONRepresentation.self) + package let tableInfo: [TableInfo] +} diff --git a/Sources/SharingGRDBCore/CloudKit/SQLiteSchema.swift b/Sources/SharingGRDBCore/CloudKit/SQLiteSchema.swift new file mode 100644 index 00000000..43c6f72c --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/SQLiteSchema.swift @@ -0,0 +1,39 @@ +import StructuredQueriesCore + +struct SQLiteSchema: QueryDecodable, QueryRepresentable { + typealias QueryValue = Self + + let type: String + let name: String + let tableName: String + let sql: String? + + init(decoder: inout some QueryDecoder) throws { + guard + let type = try decoder.decode(String.self), + let name = try decoder.decode(String.self), + let tableName = try decoder.decode(String.self) + else { + throw QueryDecodingError.missingRequiredColumn + } + self.type = type + self.name = name + self.tableName = tableName + self.sql = try decoder.decode(String.self) + } + + static var all: some StructuredQueriesCore.Statement { + SQLQueryExpression( + """ + SELECT \(columns) FROM "sqlite_schema" + """, + as: Self.self + ) + } + + static var columns: QueryFragment { + """ + "type", "name", "tbl_name", "sql" + """ + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0b7976d8..aba2227b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -105,9 +105,12 @@ self.privateTables = privateTables let allTables = try userDatabase.read { db in - try SQLQueryExpression(""" + try SQLQueryExpression( + """ SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' - """, as: String.self) + """, + as: String.self + ) .fetchAll(db) } @@ -181,6 +184,10 @@ } } + /* + + */ + let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) syncEngines.withValue { $0 = SyncEngines( @@ -192,16 +199,18 @@ try RecordType.all.fetchAll(db) } let currentRecordTypes = try userDatabase.read { db in - try SQLQueryExpression( - """ - SELECT "name", "sql" - FROM "sqlite_master" - WHERE "type" = 'table' - AND "name" IN (\(tablesByName.keys.map(\.queryFragment).joined(separator: ", "))) - """, - as: RecordType.self - ) - .fetchAll(db) + let namesAndSchemas = try SQLiteSchema.all + .fetchAll(db) + .filter { $0.type == "table" } + return try namesAndSchemas.compactMap { schema -> RecordType? in + guard let sql = schema.sql + else { return nil } + return RecordType( + tableName: schema.name, + schema: sql, + tableInfo: try TableInfo.all(schema.name).fetchAll(db) + ) + } } let recordTypesToFetch = currentRecordTypes.compactMap { currentRecordType in guard @@ -217,7 +226,6 @@ guard !recordTypesToFetch.isEmpty else { return nil } - try cacheUserTables(recordTypes: recordTypesToFetch.map(\.0)) try uploadRecordsToCloudKit( recordTypes: recordTypesToFetch.compactMap { recordType, isNewTable in isNewTable ? recordType : nil @@ -226,15 +234,14 @@ return Task { await withErrorReporting(.sqliteDataCloudKitFailure) { try await fetchChangesFromSchemaChange( - recordTypes: recordTypesToFetch.compactMap { recordType, isNewTable in - !isNewTable ? recordType : nil - } + previousRecordTypes: previousRecordTypes, + currentRecordTypes: currentRecordTypes ) } } } - private func cacheUserTables(recordTypes: [RecordType]) throws { + private func cacheUserTables(recordTypes: [RecordType]) { withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in try RecordType @@ -296,39 +303,41 @@ } } - private func fetchChangesFromSchemaChange(recordTypes: [RecordType]) async throws { - // TODO: update data from local server records, do not fetch from CloudKit - let lastKnownServerRecords = try await metadatabase.read { db in - try SyncMetadata - .where { - $0.recordType.in(recordTypes.map(\.tableName)) - && $0.lastKnownServerRecord.isNot(nil) - } - .select { - SQLQueryExpression( - "\($0.lastKnownServerRecord)", - as: CKRecord.SystemFieldsRepresentation.self - ) - } - .fetchAll(db) - } - let recordIDs = lastKnownServerRecords.map(\.recordID) - let recordIDsByDatabase = Dictionary(grouping: recordIDs) { - AnyCloudDatabase(container.database(for: $0)) - } - for (database, recordIDs) in recordIDsByDatabase { - let results = try await database.records(for: recordIDs) - for (_, result) in results { - switch result { - case .success(let record): - upsertFromServerRecord(record) - break - case .failure(let error): - reportIssue(error) - break - } + private func fetchChangesFromSchemaChange( + previousRecordTypes: [RecordType], + currentRecordTypes: [RecordType] + ) async throws { + print("!!!") + for currentRecordType in currentRecordTypes { + guard let previousRecordType = previousRecordTypes.first( + where: { $0.tableName == currentRecordType.tableName } + ) else { + continue + } + + if currentRecordType.tableInfo != previousRecordType.tableInfo { + print("!!!") } } + +// // TODO: update data from local server records, do not fetch from CloudKit +// let lastKnownServerRecords = try await metadatabase.read { db in +// try SyncMetadata +// .where { +// $0.recordType.in(recordTypes.map(\.tableName)) +// && $0._lastKnownServerRecordAllFields.isNot(nil) +// } +// .select { +// SQLQueryExpression( +// "\($0._lastKnownServerRecordAllFields)", +// as: CKRecord.AllFieldsRepresentation.self +// ) +// } +// .fetchAll(db) +// } +// for record in lastKnownServerRecords { +// //upsertFromServerRecord(record) +// } } package func tearDownSyncEngine() async throws { @@ -967,7 +976,8 @@ } let metadata = result?.0 let allFields = result?.1 - serverRecord.userModificationDate = metadata?.userModificationDate ?? serverRecord.userModificationDate + serverRecord.userModificationDate = + metadata?.userModificationDate ?? serverRecord.userModificationDate func open>(_: T.Type) throws { var columnNames = T.TableColumns.allColumns.map(\.name) @@ -1246,7 +1256,7 @@ .filter { _, tableName, _ in tableNames.contains(tableName) } let invalidTriggers = triggers.compactMap { name, _, sql in let isValid = - sql + sql .lowercased() .contains("\(DatabaseFunction.syncEngineIsUpdatingRecord.name)()".lowercased()) return isValid ? nil : name @@ -1255,7 +1265,7 @@ else { throw InvalidUserTriggers(triggers: invalidTriggers) } - + for table in tables { // // TODO: write tests for this // let columnsWithUniqueConstraints = @@ -1384,14 +1394,14 @@ } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Updates { - mutating func setLastKnownServerRecord(_ lastKnownServerRecord: CKRecord?) { - self.lastKnownServerRecord = lastKnownServerRecord - self._lastKnownServerRecordAllFields = lastKnownServerRecord - if let lastKnownServerRecord { - self.userModificationDate = lastKnownServerRecord.userModificationDate + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension Updates { + mutating func setLastKnownServerRecord(_ lastKnownServerRecord: CKRecord?) { + self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = lastKnownServerRecord + if let lastKnownServerRecord { + self.userModificationDate = lastKnownServerRecord.userModificationDate + } } } -} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/TableInfo.swift b/Sources/SharingGRDBCore/CloudKit/TableInfo.swift new file mode 100644 index 00000000..28e53335 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/TableInfo.swift @@ -0,0 +1,44 @@ +import StructuredQueriesCore + +package struct TableInfo: Codable, Hashable, QueryDecodable, QueryRepresentable { + typealias QueryValue = Self + + let defaultValue: String? + let isPrimaryKey: Bool + let name: String + let notNull: Bool + let type: String + + package init(decoder: inout some QueryDecoder) throws { + self.defaultValue = try decoder.decode(String.self) + guard + let isPrimaryKey = try decoder.decode(Bool.self), + let name = try decoder.decode(String.self), + let notNull = try decoder.decode(Bool.self), + let type = try decoder.decode(String.self) + else { + throw QueryDecodingError.missingRequiredColumn + } + self.isPrimaryKey = isPrimaryKey + self.name = name + self.notNull = notNull + self.type = type + } + + static func all( + _ tableName: String + ) -> some StructuredQueriesCore.Statement { + SQLQueryExpression( + """ + SELECT \(columns) FROM pragma_table_info(\(bind: tableName)) + """, + as: Self.self + ) + } + + static var columns: QueryFragment { + """ + "dflt_value", "pk", "name", "notnull", "type" + """ + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift index 18793fa8..11610bb8 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift @@ -33,17 +33,17 @@ extension BaseCloudKitTests { for: RemindersList.recordID(for: UUID(1)) ) personalListRecord.setValue(1, forKey: "position", at: now) - + let businessListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: UUID(2)) ) businessListRecord.setValue(2, forKey: "position", at: now) - + let reminderRecord = try syncEngine.private.database.record( for: Reminder.recordID(for: UUID(1)) ) reminderRecord.setValue(3, forKey: "position", at: now) - + await syncEngine.modifyRecords( scope: .private, saving: [personalListRecord, businessListRecord, reminderRecord] @@ -65,7 +65,7 @@ extension BaseCloudKitTests { ) .execute(db) } - + let relaunchedSyncEngine = try await SyncEngine( container: syncEngine.container, userDatabase: self.userDatabase, @@ -85,30 +85,29 @@ extension BaseCloudKitTests { RemindersListPrivate.self ] ) - + await relaunchedSyncEngine.processBatch() - + let remindersLists = try await userDatabase.userRead { db in try MigratedRemindersList.order(by: \.id).fetchAll(db) } let reminders = try await userDatabase.userRead { db in try MigratedReminder.order(by: \.id).fetchAll(db) } - withKnownIssue("This will be fixed once we properly update user database with last fetched record when schema changes") { - expectNoDifference( - remindersLists, - [ - MigratedRemindersList(id: UUID(1), title: "Personal", position: 1), - MigratedRemindersList(id: UUID(2), title: "Business", position: 2), - ] - ) - expectNoDifference( - reminders, - [ - MigratedReminder(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), - ] - ) - } + + expectNoDifference( + remindersLists, + [ + MigratedRemindersList(id: UUID(1), title: "Personal", position: 1), + MigratedRemindersList(id: UUID(2), title: "Business", position: 2), + ] + ) + expectNoDifference( + reminders, + [ + MigratedReminder(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), + ] + ) } } } From 040fdbe1bc04195b8d7dc6eb58a5256094f7d02b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 9 Jul 2025 16:28:14 -0700 Subject: [PATCH 335/581] new playground app --- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++++ .../Assets.xcassets/Contents.json | 6 + .../CloudKitPlayground.entitlements | 16 ++ .../CloudKitPlaygroundApp.swift | 64 ++++++ Examples/CloudKitPlayground/ContentView.swift | 24 +++ Examples/CloudKitPlayground/Info.plist | 12 ++ Examples/CloudKitPlayground/ModelAView.swift | 62 ++++++ Examples/CloudKitPlayground/ModelBView.swift | 46 +++++ Examples/CloudKitPlayground/ModelCView.swift | 41 ++++ Examples/CloudKitPlayground/Schema.swift | 85 ++++++++ Examples/Examples.xcodeproj/project.pbxproj | 191 ++++++++++++++++-- .../xcshareddata/swiftpm/Package.resolved | 2 +- 13 files changed, 575 insertions(+), 20 deletions(-) create mode 100644 Examples/CloudKitPlayground/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/CloudKitPlayground/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/CloudKitPlayground/Assets.xcassets/Contents.json create mode 100644 Examples/CloudKitPlayground/CloudKitPlayground.entitlements create mode 100644 Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift create mode 100644 Examples/CloudKitPlayground/ContentView.swift create mode 100644 Examples/CloudKitPlayground/Info.plist create mode 100644 Examples/CloudKitPlayground/ModelAView.swift create mode 100644 Examples/CloudKitPlayground/ModelBView.swift create mode 100644 Examples/CloudKitPlayground/ModelCView.swift create mode 100644 Examples/CloudKitPlayground/Schema.swift diff --git a/Examples/CloudKitPlayground/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/CloudKitPlayground/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Examples/CloudKitPlayground/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CloudKitPlayground/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/CloudKitPlayground/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/Examples/CloudKitPlayground/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CloudKitPlayground/Assets.xcassets/Contents.json b/Examples/CloudKitPlayground/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/CloudKitPlayground/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CloudKitPlayground/CloudKitPlayground.entitlements b/Examples/CloudKitPlayground/CloudKitPlayground.entitlements new file mode 100644 index 00000000..97100297 --- /dev/null +++ b/Examples/CloudKitPlayground/CloudKitPlayground.entitlements @@ -0,0 +1,16 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground + + com.apple.developer.icloud-services + + CloudKit + + + diff --git a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift new file mode 100644 index 00000000..1f90aeb0 --- /dev/null +++ b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift @@ -0,0 +1,64 @@ +import CloudKit +import SharingGRDB +import SwiftUI +import UIKit + +@main +struct CloudKitPlaygroundApp: App { + @UIApplicationDelegateAdaptor var delegate: AppDelegate + + init() { + prepareDependencies { + $0.defaultDatabase = try! appDatabase() + $0.defaultSyncEngine = try! SyncEngine( + container: CKContainer( + identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" + ), + database: $0.defaultDatabase, + tables: [ModelA.self, ModelB.self, ModelC.self] + ) + } + } + var body: some Scene { + WindowGroup { + NavigationStack { + ModelAView() + } + } + } +} + +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + func windowScene( + _ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + @Dependency(\.defaultSyncEngine) var syncEngine + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } +} diff --git a/Examples/CloudKitPlayground/ContentView.swift b/Examples/CloudKitPlayground/ContentView.swift new file mode 100644 index 00000000..77266cff --- /dev/null +++ b/Examples/CloudKitPlayground/ContentView.swift @@ -0,0 +1,24 @@ +// +// ContentView.swift +// CloudKitPlayground +// +// Created by Brandon Williams on 7/9/25. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/Examples/CloudKitPlayground/Info.plist b/Examples/CloudKitPlayground/Info.plist new file mode 100644 index 00000000..9ef96ef8 --- /dev/null +++ b/Examples/CloudKitPlayground/Info.plist @@ -0,0 +1,12 @@ + + + + + CKSharingSupported + + UIBackgroundModes + + remote-notification + + + diff --git a/Examples/CloudKitPlayground/ModelAView.swift b/Examples/CloudKitPlayground/ModelAView.swift new file mode 100644 index 00000000..fc85e5b6 --- /dev/null +++ b/Examples/CloudKitPlayground/ModelAView.swift @@ -0,0 +1,62 @@ +import CloudKit +import SharingGRDB +import SwiftUI + +struct ModelAView: View { + @FetchAll var models: [ModelA] + @Dependency(\.defaultDatabase) var database + @Dependency(\.defaultSyncEngine) var syncEngine + + @State var sharedRecord: SharedRecord? + + var body: some View { + List { + ForEach(models) { model in + HStack { + Button("-") { + withErrorReporting { + try database.write { db in + try ModelA.find(model.id).update { $0.count -= 1 }.execute(db) + } + } + } + Text("\(model.count)") + Button("+") { + withErrorReporting { + try database.write { db in + try ModelA.find(model.id).update { $0.count += 1 }.execute(db) + } + } + } + Spacer() + NavigationLink("Go") { + ModelBView(modelA: model) + } + Spacer() + Button { + Task { + sharedRecord = try await syncEngine.share(record: model) { share in + share[CKShare.SystemFieldKey.title] = "Join my ModelA(\(model.count))" + } + } + } label: { + Image(systemName: "square.and.arrow.up") + } + } + .buttonStyle(.plain) + } + } + .sheet(item: $sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } + .toolbar { + Button("Add") { + withErrorReporting { + try database.write { db in + try ModelA.insert { ModelA.Draft() }.execute(db) + } + } + } + } + } +} diff --git a/Examples/CloudKitPlayground/ModelBView.swift b/Examples/CloudKitPlayground/ModelBView.swift new file mode 100644 index 00000000..8a1e26f7 --- /dev/null +++ b/Examples/CloudKitPlayground/ModelBView.swift @@ -0,0 +1,46 @@ +import SharingGRDB +import SwiftUI + +struct ModelBView: View { + let modelA: ModelA + @FetchAll var models: [ModelB] + @Dependency(\.defaultDatabase) var database + + init(modelA: ModelA) { + self.modelA = modelA + _models = FetchAll(ModelB.where { $0.modelAID.eq(modelA.id) }) + } + + var body: some View { + List { + ForEach(models) { model in + HStack { + Toggle("On? \(model.isOn ? "YES" : "NO")", isOn: Binding { + model.isOn + } set: { newValue in + withErrorReporting { + try database.write { db in + try ModelB.find(model.id).update { $0.isOn = newValue }.execute(db) + } + } + }) + + Spacer() + NavigationLink("Go") { + ModelCView(modelB: model) + } + } + .buttonStyle(.plain) + } + } + .toolbar { + Button("Add") { + withErrorReporting { + try database.write { db in + try ModelB.insert { ModelB.Draft(modelAID: modelA.id) }.execute(db) + } + } + } + } + } +} diff --git a/Examples/CloudKitPlayground/ModelCView.swift b/Examples/CloudKitPlayground/ModelCView.swift new file mode 100644 index 00000000..d5d45c42 --- /dev/null +++ b/Examples/CloudKitPlayground/ModelCView.swift @@ -0,0 +1,41 @@ +import SharingGRDB +import SwiftUI + +struct ModelCView: View { + let modelB: ModelB + @FetchAll var models: [ModelC] + @Dependency(\.defaultDatabase) var database + + init(modelB: ModelB) { + self.modelB = modelB + _models = FetchAll(ModelC.where { $0.modelBID.eq(modelB.id) }) + } + + var body: some View { + List { + ForEach(models) { model in + HStack { + TextField("Title", text: Binding { + model.title + } set: { newValue in + withErrorReporting { + try database.write { db in + try ModelC.find(model.id).update { $0.title = newValue }.execute(db) + } + } + }) + } + .buttonStyle(.plain) + } + } + .toolbar { + Button("Add") { + withErrorReporting { + try database.write { db in + try ModelC.insert { ModelC.Draft(modelBID: modelB.id) }.execute(db) + } + } + } + } + } +} diff --git a/Examples/CloudKitPlayground/Schema.swift b/Examples/CloudKitPlayground/Schema.swift new file mode 100644 index 00000000..e3647306 --- /dev/null +++ b/Examples/CloudKitPlayground/Schema.swift @@ -0,0 +1,85 @@ +import Foundation +import SharingGRDB +import os + +@Table struct ModelA: Identifiable { + let id: UUID + var count = 0 +} +@Table struct ModelB: Identifiable { + let id: UUID + var isOn = false + var modelAID: ModelA.ID +} +@Table struct ModelC: Identifiable { + let id: UUID + var title = "" + var modelBID: ModelB.ID +} + +func appDatabase() throws -> any DatabaseWriter { + @Dependency(\.context) var context + let database: any DatabaseWriter + var configuration = Configuration() + configuration.foreignKeysEnabled = context != .live + configuration.prepareDatabase { db in + try db.attachMetadatabase( + containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" + ) + #if DEBUG + db.trace(options: .profile) { + if context == .live { + logger.debug("\($0.expandedDescription)") + } else { + print("\($0.expandedDescription)") + } + } + #endif + } + if context == .preview { + database = try DatabaseQueue(configuration: configuration) + } else { + let path = + context == .live + ? URL.documentsDirectory.appending(component: "db.sqlite").path() + : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + logger.debug( + """ + App database + open "\(path)" + """ + ) + database = try DatabasePool(path: path, configuration: configuration) + } + var migrator = DatabaseMigrator() + migrator.registerMigration("Create tables") { db in + try #sql(""" + CREATE TABLE "modelAs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "count" INTEGER NOT NULL + ) + """) + .execute(db) + try #sql(""" + CREATE TABLE "modelBs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "isOn" INTEGER NOT NULL, + "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE + ) + """) + .execute(db) + try #sql(""" + CREATE TABLE "modelCs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL, + "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE + ) + """) + .execute(db) + } + try migrator.migrate(database) + + return database +} + +let logger = Logger(subsystem: "CloudKitPlayground", category: "Database") diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 37e96fc8..2dace405 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -11,6 +11,10 @@ CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA42392E2DF7219E000AF560 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA42392D2DF7219E000AF560 /* SwiftUINavigation */; }; + CA9102EB2E1F299900F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EA2E1F299900F85DD0 /* SharingGRDB */; }; + CA9102ED2E1F29A400F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EC2E1F29A400F85DD0 /* SharingGRDB */; }; + CA9102EF2E1F29AA00F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */; }; + CA9102F12E1F29E300F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102F02E1F29E300F85DD0 /* SharingGRDB */; }; CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99D72DF915D300934431 /* DependenciesTestSupport */; }; CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */; }; CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */; }; @@ -19,8 +23,6 @@ DC7082542E035FC500A66B7D /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DC7082532E035FC500A66B7D /* SwiftUINavigation */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8A2E02176700FB20F8 /* SharingGRDB */; }; - DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8C2E02177200FB20F8 /* SharingGRDB */; }; - DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8E2E02177900FB20F8 /* SharingGRDB */; }; DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCF267382D48437300B680BE /* SwiftUINavigation */; }; /* End PBXBuildFile section */ @@ -59,6 +61,8 @@ CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudKitDemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CA5F37542D5AFBBC002E1A9E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + CA9101C82E1F270100F85DD0 /* CloudKitPlayground.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitPlayground.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CA9102E62E1F28FE00F85DD0 /* sharing-grdb-icloud */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "sharing-grdb-icloud"; path = "/Users/brandon/projects/sharing-grdb-icloud"; sourceTree = ""; }; CA9F99482DF9134D00934431 /* RemindersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RemindersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836982D4735620047AEB5 /* CaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -75,6 +79,13 @@ ); target = CA11469E2DF38CFE0054BA77 /* CloudKitDemo */; }; + CA9101D52E1F272700F85DD0 /* Exceptions for "CloudKitPlayground" folder in "CloudKitPlayground" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = CA9101C72E1F270100F85DD0 /* CloudKitPlayground */; + }; CAD4819A2D584B510004799A /* Exceptions for "CaseStudies" folder in "CaseStudies" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -114,6 +125,14 @@ path = CloudKitDemoTests; sourceTree = ""; }; + CA9101C92E1F270100F85DD0 /* CloudKitPlayground */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CA9101D52E1F272700F85DD0 /* Exceptions for "CloudKitPlayground" folder in "CloudKitPlayground" target */, + ); + path = CloudKitPlayground; + sourceTree = ""; + }; CA9F99492DF9134D00934431 /* RemindersTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = RemindersTests; @@ -172,6 +191,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CA9101C52E1F270100F85DD0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CA9102F12E1F29E300F85DD0 /* SharingGRDB in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CA9F99452DF9134D00934431 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -195,6 +222,7 @@ buildActionMask = 2147483647; files = ( DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */, + CA9102EB2E1F299900F85DD0 /* SharingGRDB in Frameworks */, CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -211,7 +239,7 @@ buildActionMask = 2147483647; files = ( CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, - DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */, + CA9102ED2E1F29A400F85DD0 /* SharingGRDB in Frameworks */, DC7082542E035FC500A66B7D /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -222,7 +250,7 @@ files = ( DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */, DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */, - DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */, + CA9102EF2E1F29AA00F85DD0 /* SharingGRDB in Frameworks */, DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -242,6 +270,7 @@ CAD0017E2D874E6F00FA977A /* SyncUpTests */, CA1146A02DF38CFE0054BA77 /* CloudKitDemo */, CA1146AF2DF38D000054BA77 /* CloudKitDemoTests */, + CA9101C92E1F270100F85DD0 /* CloudKitPlayground */, CAF837022D4735C00047AEB5 /* Frameworks */, CAF836992D4735620047AEB5 /* Products */, ); @@ -258,6 +287,7 @@ CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */, CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */, CA9F99482DF9134D00934431 /* RemindersTests.xctest */, + CA9101C82E1F270100F85DD0 /* CloudKitPlayground.app */, ); name = Products; sourceTree = ""; @@ -265,6 +295,7 @@ CAF837022D4735C00047AEB5 /* Frameworks */ = { isa = PBXGroup; children = ( + CA9102E62E1F28FE00F85DD0 /* sharing-grdb-icloud */, ); name = Frameworks; sourceTree = ""; @@ -319,6 +350,29 @@ productReference = CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + CA9101C72E1F270100F85DD0 /* CloudKitPlayground */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA9101D22E1F270200F85DD0 /* Build configuration list for PBXNativeTarget "CloudKitPlayground" */; + buildPhases = ( + CA9101C42E1F270100F85DD0 /* Sources */, + CA9101C52E1F270100F85DD0 /* Frameworks */, + CA9101C62E1F270100F85DD0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + CA9101C92E1F270100F85DD0 /* CloudKitPlayground */, + ); + name = CloudKitPlayground; + packageProductDependencies = ( + CA9102F02E1F29E300F85DD0 /* SharingGRDB */, + ); + productName = CloudKitPlayground; + productReference = CA9101C82E1F270100F85DD0 /* CloudKitPlayground.app */; + productType = "com.apple.product-type.application"; + }; CA9F99472DF9134D00934431 /* RemindersTests */ = { isa = PBXNativeTarget; buildConfigurationList = CA9F99502DF9134D00934431 /* Build configuration list for PBXNativeTarget "RemindersTests" */; @@ -388,6 +442,7 @@ packageProductDependencies = ( CA2908C82D4AF70E003F165F /* UIKitNavigation */, DCD9AC8A2E02176700FB20F8 /* SharingGRDB */, + CA9102EA2E1F299900F85DD0 /* SharingGRDB */, ); productName = Examples; productReference = CAF836982D4735620047AEB5 /* CaseStudies.app */; @@ -434,8 +489,8 @@ name = Reminders; packageProductDependencies = ( CA14DBC82DA884C400E36852 /* CasePaths */, - DCD9AC8C2E02177200FB20F8 /* SharingGRDB */, DC7082532E035FC500A66B7D /* SwiftUINavigation */, + CA9102EC2E1F29A400F85DD0 /* SharingGRDB */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -461,7 +516,7 @@ DCBE8A132D4842BF0071F499 /* CasePaths */, DCF267382D48437300B680BE /* SwiftUINavigation */, DC5FA7472D4C63D60082743E /* DependenciesMacros */, - DCD9AC8E2E02177900FB20F8 /* SharingGRDB */, + CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */, ); productName = SyncUps; productReference = DCBE89CC2D483FB90071F499 /* SyncUps.app */; @@ -484,6 +539,9 @@ CreatedOnToolsVersion = 16.4; TestTargetID = CA11469E2DF38CFE0054BA77; }; + CA9101C72E1F270100F85DD0 = { + CreatedOnToolsVersion = 16.4; + }; CA9F99472DF9134D00934431 = { CreatedOnToolsVersion = 16.4; TestTargetID = CAF836D72D4735AB0047AEB5; @@ -520,7 +578,7 @@ DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */, DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, - DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */, + CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference "../../sharing-grdb-icloud" */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -535,6 +593,7 @@ CAD0017C2D874E6F00FA977A /* SyncUpTests */, CA11469E2DF38CFE0054BA77 /* CloudKitDemo */, CA1146AB2DF38D000054BA77 /* CloudKitDemoTests */, + CA9101C72E1F270100F85DD0 /* CloudKitPlayground */, ); }; /* End PBXProject section */ @@ -554,6 +613,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CA9101C62E1F270100F85DD0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CA9F99462DF9134D00934431 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -613,6 +679,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CA9101C42E1F270100F85DD0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CA9F99442DF9134D00934431 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -807,6 +880,68 @@ }; name = Release; }; + CA9101D02E1F270200F85DD0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CloudKitPlayground/CloudKitPlayground.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CloudKitPlayground/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitPlayground; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CA9101D12E1F270200F85DD0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CloudKitPlayground/CloudKitPlayground.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VFRXY8HC3H; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CloudKitPlayground/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitPlayground; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; CA9F99512DF9134D00934431 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1223,6 +1358,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CA9101D22E1F270200F85DD0 /* Build configuration list for PBXNativeTarget "CloudKitPlayground" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA9101D02E1F270200F85DD0 /* Debug */, + CA9101D12E1F270200F85DD0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; CA9F99502DF9134D00934431 /* Build configuration list for PBXNativeTarget "RemindersTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1289,9 +1433,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */ = { + CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference "../../sharing-grdb-icloud" */ = { isa = XCLocalSwiftPackageReference; - relativePath = ..; + relativePath = "../../sharing-grdb-icloud"; }; /* End XCLocalSwiftPackageReference section */ @@ -1350,6 +1494,25 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = SwiftUINavigation; }; + CA9102EA2E1F299900F85DD0 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + productName = SharingGRDB; + }; + CA9102EC2E1F29A400F85DD0 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference "../../sharing-grdb-icloud" */; + productName = SharingGRDB; + }; + CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference "../../sharing-grdb-icloud" */; + productName = SharingGRDB; + }; + CA9102F02E1F29E300F85DD0 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference "../../sharing-grdb-icloud" */; + productName = SharingGRDB; + }; CA9F99D72DF915D300934431 /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; @@ -1389,16 +1552,6 @@ isa = XCSwiftPackageProductDependency; productName = SharingGRDB; }; - DCD9AC8C2E02177200FB20F8 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */; - productName = SharingGRDB; - }; - DCD9AC8E2E02177900FB20F8 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */; - productName = SharingGRDB; - }; DCF267382D48437300B680BE /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 79c43a71..a0b54343 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b6534cfa47456b954c745e8e62752cccc8a947728cdc9341f6f64009bb32aef4", + "originHash" : "e0bc29bbde5e26d585f494e6c089d6351112f198250c9fe9a3326c6962e38c7c", "pins" : [ { "identity" : "combine-schedulers", From 37065297a8e2f0d1ff77b3c4cdcf8d52086d6c78 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 9 Jul 2025 16:35:50 -0700 Subject: [PATCH 336/581] wip --- Examples/CloudKitPlayground/ModelAView.swift | 9 ++++++++ Examples/CloudKitPlayground/ModelBView.swift | 24 ++++++++++++++++++++ Examples/CloudKitPlayground/ModelCView.swift | 9 ++++++++ 3 files changed, 42 insertions(+) diff --git a/Examples/CloudKitPlayground/ModelAView.swift b/Examples/CloudKitPlayground/ModelAView.swift index fc85e5b6..5ad88823 100644 --- a/Examples/CloudKitPlayground/ModelAView.swift +++ b/Examples/CloudKitPlayground/ModelAView.swift @@ -45,6 +45,15 @@ struct ModelAView: View { } .buttonStyle(.plain) } + .onDelete { indexSet in + for index in indexSet { + withErrorReporting { + try database.write { db in + try ModelA.find(models[index].id).delete().execute(db) + } + } + } + } } .sheet(item: $sharedRecord) { sharedRecord in CloudSharingView(sharedRecord: sharedRecord) diff --git a/Examples/CloudKitPlayground/ModelBView.swift b/Examples/CloudKitPlayground/ModelBView.swift index 8a1e26f7..f34c0091 100644 --- a/Examples/CloudKitPlayground/ModelBView.swift +++ b/Examples/CloudKitPlayground/ModelBView.swift @@ -32,6 +32,15 @@ struct ModelBView: View { } .buttonStyle(.plain) } + .onDelete { indexSet in + for index in indexSet { + withErrorReporting { + try database.write { db in + try ModelB.find(models[index].id).delete().execute(db) + } + } + } + } } .toolbar { Button("Add") { @@ -41,6 +50,21 @@ struct ModelBView: View { } } } + Button("Special") { + withErrorReporting { + try database.write { db in + let modelB = try ModelB.insert { ModelB.Draft(modelAID: modelA.id) }.returning(\.self).fetchOne(db) + guard let modelB + else { return } + + for index in 1...5 { + try ModelC + .insert { ModelC.Draft.init(title: index.description, modelBID: modelB.id) } + .execute(db) + } + } + } + } } } } diff --git a/Examples/CloudKitPlayground/ModelCView.swift b/Examples/CloudKitPlayground/ModelCView.swift index d5d45c42..329a622b 100644 --- a/Examples/CloudKitPlayground/ModelCView.swift +++ b/Examples/CloudKitPlayground/ModelCView.swift @@ -27,6 +27,15 @@ struct ModelCView: View { } .buttonStyle(.plain) } + .onDelete { indexSet in + for index in indexSet { + withErrorReporting { + try database.write { db in + try ModelC.find(models[index].id).delete().execute(db) + } + } + } + } } .toolbar { Button("Add") { From f855f127792f991fc633997961ac718a2b71329f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 07:59:56 -0700 Subject: [PATCH 337/581] Topological sort saves --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 10 ++++- .../CloudKitTests/AssetsTests.swift | 2 +- .../FetchRecordZoneChangesTests.swift | 42 +++++++++++++++++-- .../Internal/CloudKitTestHelpers.swift | 10 +++++ 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0b7976d8..99f26a32 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -544,8 +544,14 @@ let changes = allChanges.sorted { lhs, rhs in switch (lhs, rhs) { - case (.saveRecord, .saveRecord): - return true + case (.saveRecord(let lhs), .saveRecord(let rhs)): + guard + let lhsRecordName = SyncMetadata.RecordName(rawValue: lhs.recordName), + let lhsIndex = tablesByOrder[lhsRecordName.recordType], + let rhsRecordName = SyncMetadata.RecordName(rawValue: rhs.recordName), + let rhsIndex = tablesByOrder[rhsRecordName.recordType] + else { return true } + return lhsIndex < rhsIndex case (.deleteRecord(let lhs), .deleteRecord(let rhs)): guard let lhsRecordName = SyncMetadata.RecordName(rawValue: lhs.recordName), diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index 4215dd5c..97a27172 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -155,7 +155,7 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords( scope: .private, - saving: [remindersListAssetRecord, remindersListRecord] + saving: [remindersListRecord, remindersListAssetRecord, ] ) try { diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index aa870cc3..eded1c63 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -291,6 +291,13 @@ extension BaseCloudKitTests { } @Test func receiveNewRecordFromCloudKit_ChildBeforeParent() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: UUID(1)) + ) + remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let reminderRecord = CKRecord( recordType: Reminder.tableName, recordID: Reminder.recordID(for: UUID(1)) @@ -303,6 +310,7 @@ extension BaseCloudKitTests { action: .none ) + let remindersListModification = { syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) }() await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) assertInlineSnapshot(of: syncEngine.container, as: .customDump) { @@ -319,6 +327,14 @@ extension BaseCloudKitTests { id: "00000000-0000-0000-0000-000000000001", remindersListID: "00000000-0000-0000-0000-000000000001", title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal" ) ] ), @@ -330,15 +346,27 @@ extension BaseCloudKitTests { """ } + await remindersListModification() + try { try userDatabase.read { db in - let metadata = try #require( + let reminderMetadata = try #require( try SyncMetadata.find(Reminder.recordName(for: UUID(1))).fetchOne(db) ) - #expect(metadata.recordName == Reminder.recordName(for: UUID(1))) - #expect(metadata.parentRecordName == RemindersList.recordName(for: UUID(1))) + #expect(reminderMetadata.recordName == Reminder.recordName(for: UUID(1))) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(1))) + + let remindersListMetadata = try #require( + try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) + ) + #expect(remindersListMetadata.recordName == RemindersList.recordName(for: UUID(1))) + #expect(remindersListMetadata.parentRecordName == nil) + let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) #expect(reminder == Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1))) + + let remindersList = try #require(try RemindersList.find(UUID(1)).fetchOne(db)) + #expect(remindersList == RemindersList(id: UUID(1), title: "Personal")) } }() @@ -367,6 +395,14 @@ extension BaseCloudKitTests { isCompleted: 0, remindersListID: "00000000-0000-0000-0000-000000000001", title: "Buy milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal" ) ] ), diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 5f30f80d..d6ecc74f 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -282,6 +282,16 @@ final class MockCloudDatabase: CloudDatabase { let existingRecord = storage[recordToSave.recordID] func saveRecordToDatabase() { + let parentRecordExists = recordToSave.parent == nil || storage.values.contains { record in + record.recordID != recordToSave.recordID + && recordToSave.parent?.recordID == record.recordID + } + guard parentRecordExists + else { + saveResults[recordToSave.recordID] = .failure(CKError(.referenceViolation)) + return + } + guard let copy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } copy._recordChangeTag = UUID().uuidString From 75b486af7b559daedb9d8126856e5cb51660422c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 10 Jul 2025 11:14:33 -0700 Subject: [PATCH 338/581] Update Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift --- Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index 97a27172..6e4e3853 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -155,7 +155,7 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords( scope: .private, - saving: [remindersListRecord, remindersListAssetRecord, ] + saving: [remindersListRecord, remindersListAssetRecord] ) try { From 37553028b577988660ac1a1bc1e2977c897bc5dd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 11:16:57 -0700 Subject: [PATCH 339/581] Find root record when saving/deleting. --- .../xcshareddata/swiftpm/Package.resolved | 20 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 104 ++++--- .../SyncMetadata+MacroExpansion.swift | 277 ++++++++++++------ .../CloudKit/SyncMetadata.swift | 11 +- .../SharingGRDBCore/CloudKit/Triggers.swift | 58 +++- .../CloudKitTests/CloudKitTests.swift | 29 ++ .../CloudKitTests/RecordTypeTests.swift | 29 ++ .../CloudKitTests/SharingTests.swift | 148 ++++++++++ .../CloudKitTests/TriggerTests.swift | 229 ++++++++++++--- .../Internal/BaseCloudKitTests.swift | 5 +- .../Internal/CloudKitTestHelpers.swift | 2 + Tests/SharingGRDBTests/Internal/Schema.swift | 37 +++ 12 files changed, 752 insertions(+), 197 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a0b54343..0560f673 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e0bc29bbde5e26d585f494e6c089d6351112f198250c9fe9a3326c6962e38c7c", + "originHash" : "1a7bc0659d563286d3726b3ca6b02007396575ec65be2095b61b5cc9e3846e25", "pins" : [ { "identity" : "combine-schedulers", @@ -73,6 +73,24 @@ "version" : "1.9.2" } }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 99f26a32..5bf2a38c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -105,9 +105,12 @@ self.privateTables = privateTables let allTables = try userDatabase.read { db in - try SQLQueryExpression(""" + try SQLQueryExpression( + """ SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' - """, as: String.self) + """, + as: String.self + ) .fetchAll(db) } @@ -887,11 +890,15 @@ clearServerRecord() case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, - .notAuthenticated, - .operationCancelled, .batchRequestFailed: + .notAuthenticated, .referenceViolation, .operationCancelled, .batchRequestFailed, + .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, + .permissionFailure, .invalidArguments, .resultsTruncated, .assetFileNotFound, + .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired, + .badDatabase, .quotaExceeded, .limitExceeded, .userDeletedZone, .tooManyParticipants, + .alreadyShared, .managedAccountRestricted, .participantMayNeedVerification, + .serverResponseLost, .assetNotAvailable, .accountTemporarilyUnavailable: continue - - default: + @unknown default: continue } } @@ -973,7 +980,8 @@ } let metadata = result?.0 let allFields = result?.1 - serverRecord.userModificationDate = metadata?.userModificationDate ?? serverRecord.userModificationDate + serverRecord.userModificationDate = + metadata?.userModificationDate ?? serverRecord.userModificationDate func open>(_: T.Type) throws { var columnNames = T.TableColumns.allColumns.map(\.name) @@ -993,39 +1001,41 @@ ) } - var query: QueryFragment = "INSERT INTO \(T.self) (" - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) - query.append(") VALUES (") - let encryptedValues = serverRecord.encryptedValues - query.append( - columnNames - .map { columnName in - if let asset = serverRecord[columnName] as? CKAsset { - @Dependency(\.dataManager) var dataManager - return (try? asset.fileURL.map { try dataManager.load($0) })? - .queryFragment ?? "NULL" - } else { - return encryptedValues[columnName]?.queryFragment ?? "NULL" - } - } - .joined(separator: ", ") - ) - query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET ") - - query.append( - columnNames - .filter { columnName in columnName != T.columns.primaryKey.name } - .map { - """ - \(quote: $0) = "excluded".\(quote: $0) - """ - } - .joined(separator: ",") - ) // TODO: Append more ON CONFLICT clauses for each unique constraint? // TODO: Use WHERE to scope the update? try userDatabase.write { db in - try SQLQueryExpression(query).execute(db) + // TODO: Write a test for this: server sends record with nothing changed + if columnNames.contains(where: { $0 != T.columns.primaryKey.name }) { + var query: QueryFragment = "INSERT INTO \(T.self) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + let encryptedValues = serverRecord.encryptedValues + query.append( + columnNames + .map { columnName in + if let asset = serverRecord[columnName] as? CKAsset { + @Dependency(\.dataManager) var dataManager + return (try? asset.fileURL.map { try dataManager.load($0) })? + .queryFragment ?? "NULL" + } else { + return encryptedValues[columnName]?.queryFragment ?? "NULL" + } + } + .joined(separator: ", ") + ) + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET ") + query.append( + columnNames + .filter { columnName in columnName != T.columns.primaryKey.name } + .map { + """ + \(quote: $0) = "excluded".\(quote: $0) + """ + } + .joined(separator: ",") + ) + try SQLQueryExpression(query).execute(db) + } try SyncMetadata .find(recordName) .update { $0.setLastKnownServerRecord(serverRecord) } @@ -1252,7 +1262,7 @@ .filter { _, tableName, _ in tableNames.contains(tableName) } let invalidTriggers = triggers.compactMap { name, _, sql in let isValid = - sql + sql .lowercased() .contains("\(DatabaseFunction.syncEngineIsUpdatingRecord.name)()".lowercased()) return isValid ? nil : name @@ -1261,7 +1271,7 @@ else { throw InvalidUserTriggers(triggers: invalidTriggers) } - + for table in tables { // // TODO: write tests for this // let columnsWithUniqueConstraints = @@ -1390,14 +1400,14 @@ } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Updates { - mutating func setLastKnownServerRecord(_ lastKnownServerRecord: CKRecord?) { - self.lastKnownServerRecord = lastKnownServerRecord - self._lastKnownServerRecordAllFields = lastKnownServerRecord - if let lastKnownServerRecord { - self.userModificationDate = lastKnownServerRecord.userModificationDate + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension Updates { + mutating func setLastKnownServerRecord(_ lastKnownServerRecord: CKRecord?) { + self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = lastKnownServerRecord + if let lastKnownServerRecord { + self.userModificationDate = lastKnownServerRecord.userModificationDate + } } } -} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 3fc4f1f4..612bde0d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -1,51 +1,146 @@ #if canImport(CloudKit) -import CloudKit + import CloudKit -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - public struct TableColumns: StructuredQueriesCore.TableDefinition, PrimaryKeyedTableDefinition { - public typealias QueryValue = SyncMetadata - public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) - public var primaryKey: StructuredQueriesCore.TableColumn { - self.recordName - } - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] - } - } - - public struct Draft: StructuredQueriesCore.TableDraft { - public typealias PrimaryTable = SyncMetadata - public var recordType: String - public var recordName: RecordName? - public var parentRecordName: RecordName? - public var lastKnownServerRecord: CKRecord? - public var share: CKShare? - public var userModificationDate: Date - public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = Draft - public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { + public struct TableColumns: StructuredQueriesCore.TableDefinition, PrimaryKeyedTableDefinition { + public typealias QueryValue = SyncMetadata + public let recordType = StructuredQueriesCore.TableColumn( + "recordType", + keyPath: \QueryValue.recordType + ) + public let recordName = StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + public let parentRecordName = StructuredQueriesCore.TableColumn( + "parentRecordName", + keyPath: \QueryValue.parentRecordName + ) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.SystemFieldsRepresentation + >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn< + QueryValue, CKShare?.SystemFieldsRepresentation + >("share", keyPath: \QueryValue.share) + public let userModificationDate = StructuredQueriesCore.TableColumn( + "userModificationDate", + keyPath: \QueryValue.userModificationDate + ) + public var primaryKey: StructuredQueriesCore.TableColumn { + self.recordName + } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] + [ + QueryValue.columns.recordType, QueryValue.columns.recordName, + QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, + QueryValue.columns.share, QueryValue.columns.userModificationDate, + ] } } - public static let columns = TableColumns() - public static let tableName = SyncMetadata.tableName + public struct Draft: StructuredQueriesCore.TableDraft { + public typealias PrimaryTable = SyncMetadata + public var recordType: String + public var recordName: RecordName? + public var parentRecordName: RecordName? + public var lastKnownServerRecord: CKRecord? + public var share: CKShare? + public var userModificationDate: Date + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = Draft + public let recordType = StructuredQueriesCore.TableColumn( + "recordType", + keyPath: \QueryValue.recordType + ) + public let recordName = StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + public let parentRecordName = StructuredQueriesCore.TableColumn( + "parentRecordName", + keyPath: \QueryValue.parentRecordName + ) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.SystemFieldsRepresentation + >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn< + QueryValue, CKShare?.SystemFieldsRepresentation + >("share", keyPath: \QueryValue.share) + public let userModificationDate = StructuredQueriesCore.TableColumn( + "userModificationDate", + keyPath: \QueryValue.userModificationDate + ) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [ + QueryValue.columns.recordType, QueryValue.columns.recordName, + QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, + QueryValue.columns.share, QueryValue.columns.userModificationDate, + ] + } + } + public static let columns = TableColumns() + + public static let tableName = SyncMetadata.tableName + + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let recordType = try decoder.decode(String.self) + self.recordName = try decoder.decode(RecordName.self) + self.parentRecordName = try decoder.decode(RecordName.self) + let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) + let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) + let userModificationDate = try decoder.decode(Date.self) + guard let recordType else { + throw QueryDecodingError.missingRequiredColumn + } + guard let lastKnownServerRecord else { + throw QueryDecodingError.missingRequiredColumn + } + guard let share else { + throw QueryDecodingError.missingRequiredColumn + } + guard let userModificationDate else { + throw QueryDecodingError.missingRequiredColumn + } + self.recordType = recordType + self.lastKnownServerRecord = lastKnownServerRecord + self.share = share + self.userModificationDate = userModificationDate + } + + public init(_ other: SyncMetadata) { + self.recordType = other.recordType + self.recordName = other.recordName + self.parentRecordName = other.parentRecordName + self.lastKnownServerRecord = other.lastKnownServerRecord + self.share = other.share + self.userModificationDate = other.userModificationDate + } + public init( + recordType: String, + recordName: RecordName? = nil, + parentRecordName: RecordName? = nil, + lastKnownServerRecord: CKRecord? = nil, + share: CKShare? = nil, + userModificationDate: Date + ) { + self.recordType = recordType + self.recordName = recordName + self.parentRecordName = parentRecordName + self.lastKnownServerRecord = lastKnownServerRecord + self.share = share + self.userModificationDate = userModificationDate + } + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata: StructuredQueriesCore.Table, PrimaryKeyedTable { + public static let columns = TableColumns() + public static let tableName = "sqlitedata_icloud_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordType = try decoder.decode(String.self) - self.recordName = try decoder.decode(RecordName.self) + let recordName = try decoder.decode(RecordName.self) self.parentRecordName = try decoder.decode(RecordName.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) @@ -53,6 +148,9 @@ extension SyncMetadata { guard let recordType else { throw QueryDecodingError.missingRequiredColumn } + guard let recordName else { + throw QueryDecodingError.missingRequiredColumn + } guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } @@ -62,68 +160,65 @@ extension SyncMetadata { guard let userModificationDate else { throw QueryDecodingError.missingRequiredColumn } - self.recordType = recordType - self.lastKnownServerRecord = lastKnownServerRecord - self.share = share - self.userModificationDate = userModificationDate - } - - public init(_ other: SyncMetadata) { - self.recordType = other.recordType - self.recordName = other.recordName - self.parentRecordName = other.parentRecordName - self.lastKnownServerRecord = other.lastKnownServerRecord - self.share = other.share - self.userModificationDate = other.userModificationDate - } - public init( - recordType: String, - recordName: RecordName? = nil, - parentRecordName: RecordName? = nil, - lastKnownServerRecord: CKRecord? = nil, - share: CKShare? = nil, - userModificationDate: Date - ) { self.recordType = recordType self.recordName = recordName - self.parentRecordName = parentRecordName self.lastKnownServerRecord = lastKnownServerRecord self.share = share self.userModificationDate = userModificationDate } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata: StructuredQueriesCore.Table, PrimaryKeyedTable { - public static let columns = TableColumns() - public static let tableName = "sqlitedata_icloud_metadata" - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let recordType = try decoder.decode(String.self) - let recordName = try decoder.decode(RecordName.self) - self.parentRecordName = try decoder.decode(RecordName.self) - let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) - let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) - let userModificationDate = try decoder.decode(Date.self) - guard let recordType else { - throw QueryDecodingError.missingRequiredColumn - } - guard let recordName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let lastKnownServerRecord else { - throw QueryDecodingError.missingRequiredColumn + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata.AncestorMetadata: StructuredQueriesCore.Table { + public struct Columns: StructuredQueriesCore.QueryExpression { + public typealias QueryValue = SyncMetadata.AncestorMetadata + public let queryFragment: StructuredQueriesCore.QueryFragment + public init( + recordName: some StructuredQueriesCore.QueryExpression, + parentRecordName: some StructuredQueriesCore.QueryExpression, + lastKnownServerRecord: some StructuredQueriesCore.QueryExpression + ) { + self.queryFragment = """ + \(recordName.queryFragment) AS "recordName", \(parentRecordName.queryFragment) AS "parentRecordName", \(lastKnownServerRecord.queryFragment) AS "lastKnownServerRecord" + """ + } } - guard let share else { - throw QueryDecodingError.missingRequiredColumn + + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = SyncMetadata.AncestorMetadata + public let recordName = StructuredQueriesCore.TableColumn< + QueryValue, SyncMetadata.RecordName + >("recordName", keyPath: \QueryValue.recordName) + public let parentRecordName = StructuredQueriesCore.TableColumn< + QueryValue, SyncMetadata.RecordName? + >("parentRecordName", keyPath: \QueryValue.parentRecordName) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn( + "lastKnownServerRecord", + keyPath: \QueryValue.lastKnownServerRecord + ) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.recordName, QueryValue.columns.parentRecordName] + } } - guard let userModificationDate else { - throw QueryDecodingError.missingRequiredColumn + + public static let columns = TableColumns() + public static let tableName = "ancestorMetadatas" + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let recordName = try decoder.decode(SyncMetadata.RecordName.self) + let parentRecordName = try decoder.decode(SyncMetadata.RecordName?.self) + let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) + guard let recordName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let parentRecordName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let lastKnownServerRecord else { + throw QueryDecodingError.missingRequiredColumn + } + self.recordName = recordName + self.parentRecordName = parentRecordName + self.lastKnownServerRecord = lastKnownServerRecord } - self.recordType = recordType - self.recordName = recordName - self.lastKnownServerRecord = lastKnownServerRecord - self.share = share - self.userModificationDate = userModificationDate } -} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index f45420dc..2ae7ac01 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -62,10 +62,7 @@ public struct SyncMetadata: Hashable, Sendable { self.share = share self.userModificationDate = userModificationDate } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { public struct RecordName: RawRepresentable, Sendable, Hashable, QueryBindable { public var recordType: String public var id: UUID @@ -106,6 +103,14 @@ extension SyncMetadata { "\(id.uuidString.lowercased()):\(recordType)" } } + + // @Selection @Table + struct AncestorMetadata { + let recordName: SyncMetadata.RecordName + let parentRecordName: SyncMetadata.RecordName? + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let lastKnownServerRecord: CKRecord? + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 315ee8b8..01b81b9d 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -111,6 +111,56 @@ extension SyncMetadata { ) } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +func rootServerRecord( + recordName: some QueryExpression +) -> some QueryExpression { + With { + SyncMetadata + .find(recordName) + .select { + SyncMetadata.AncestorMetadata.Columns( + recordName: $0.recordName, + parentRecordName: $0.parentRecordName, + lastKnownServerRecord: $0.lastKnownServerRecord + ) + } + .union( + all: true, + SyncMetadata + .select { + SyncMetadata.AncestorMetadata.Columns( + recordName: $0.recordName, + parentRecordName: $0.parentRecordName, + lastKnownServerRecord: $0.lastKnownServerRecord + ) + } + .join(SyncMetadata.AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) } + ) + } query: { + SyncMetadata.AncestorMetadata + .select(\.lastKnownServerRecord) + .where { $0.parentRecordName.is(nil) } + } +} + +/* + WITH RECURSIVE ancestry(recordName, parentRecordName) AS ( + SELECT recordName, parentRecordName FROM sqlitedata_icloud_metadata WHERE recordName = 'fadbb91c-4565-4292-aa6e-579957f82371:modelCs' + UNION ALL + SELECT u.recordName, u.parentRecordName FROM sqlitedata_icloud_metadata u + JOIN ancestry a ON u.recordName = a.parentRecordName + ) + SELECT recordName FROM ancestry + WHERE parentRecordName IS NULL; + */ + + + + + +// TODO: can we remove a layer of didUpdate/didDelete? extension QueryExpression where Self == SQLQueryExpression<()> { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) fileprivate static func didUpdate( @@ -119,9 +169,7 @@ extension QueryExpression where Self == SQLQueryExpression<()> { .didUpdate( recordName: new.recordName, lastKnownServerRecord: new.lastKnownServerRecord - ?? SyncMetadata - .where { $0.recordName.is(new.parentRecordName) } - .select(\.lastKnownServerRecord) + ?? rootServerRecord(recordName: new.recordName) ) } @@ -134,9 +182,7 @@ extension QueryExpression where Self == SQLQueryExpression<()> { .didDelete( recordName: old.recordName, lastKnownServerRecord: old.lastKnownServerRecord - ?? SyncMetadata - .where { $0.recordName.is(old.parentRecordName) } - .select(\.lastKnownServerRecord) + ?? rootServerRecord(recordName: old.recordName) ) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 14781160..e8d98a1c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -117,6 +117,35 @@ extension BaseCloudKitTests { "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """ + ), + [10]: RecordType( + tableName: "modelAs", + schema: """ + CREATE TABLE "modelAs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "count" INTEGER NOT NULL + ) + """ + ), + [11]: RecordType( + tableName: "modelBs", + schema: """ + CREATE TABLE "modelBs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "isOn" INTEGER NOT NULL, + "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE + ) + """ + ), + [12]: RecordType( + tableName: "modelCs", + schema: """ + CREATE TABLE "modelCs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL, + "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE + ) + """ ) ] """# diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index ce68bee5..13b86942 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -114,6 +114,35 @@ extension BaseCloudKitTests { "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """ + ), + [10]: RecordType( + tableName: "modelAs", + schema: """ + CREATE TABLE "modelAs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "count" INTEGER NOT NULL + ) + """ + ), + [11]: RecordType( + tableName: "modelBs", + schema: """ + CREATE TABLE "modelBs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "isOn" INTEGER NOT NULL, + "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE + ) + """ + ), + [12]: RecordType( + tableName: "modelCs", + schema: """ + CREATE TABLE "modelCs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL, + "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE + ) + """ ) ] """# diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 4e1d25ff..37f2164f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -123,6 +123,77 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareeCreatesMultipleChildModels() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + + let modelARecord = CKRecord( + recordType: ModelA.tableName, + recordID: ModelA.recordID(for: UUID(1), zoneID: externalZoneID) + ) + modelARecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + modelARecord.setValue(0, forKey: "count", at: now) + + await syncEngine.modifyRecords(scope: .shared, saving: [modelARecord]) + + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + ModelB(id: UUID(1), modelAID: UUID(1)) + ModelC(id: UUID(1), modelBID: UUID(1)) + } + } + } + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/external.zone/external.owner), + recordType: "modelAs", + parent: nil, + share: nil, + count: 0, + id: "00000000-0000-0000-0000-000000000001" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), + recordType: "modelBs", + parent: CKReference(recordID: CKRecord.ID(1:modelAs/external.zone/external.owner)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isOn: 0, + modelAID: "00000000-0000-0000-0000-000000000001" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), + recordType: "modelCs", + parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + modelBID: "00000000-0000-0000-0000-000000000001", + title: "" + ) + ] + ) + ) + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteRecordInExternallySharedRecord() async throws { let externalZoneID = CKRecordZone.ID( @@ -180,6 +251,83 @@ extension BaseCloudKitTests { """ } } + +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func shareeCreatesMultipleRecords() async throws { +// let otherSycnEngine = try await withDependencies { +// $0.defaultOwnerName = "other-owner" +// } operation: { +// try await SyncEngine( +// container: MockCloudContainer( +// containerIdentifier: testContainerIdentifier, +// privateCloudDatabase: MockCloudDatabase(databaseScope: .private), +// sharedCloudDatabase: syncEngine.shared.database +// ), +// userDatabase: self.userDatabase, +// metadatabaseURL: URL.metadatabase(containerIdentifier: testContainerIdentifier), +// tables: [ +// ModelA.self, +// ModelB.self, +// ModelC.self, +// ] +// ) +// } +// +// let rootModel = ModelA(id: UUID(1)) +// try await userDatabase.userWrite { db in +// try db.seed { +// rootModel +// ModelB(id: UUID(1), modelAID: UUID(1)) +// ModelC(id: UUID(1), modelBID: UUID(1)) +// } +// } +// +// await syncEngine.processBatch() +// +// let share = try await syncEngine.share(record: rootModel) { _ in } +// +// assertInlineSnapshot(of: syncEngine.container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [ +// [0]: CKRecord( +// recordID: CKRecord.ID(75140E4A-A949-427F-96D9-88DAC532844A/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), +// recordType: "cloudkit.share", +// parent: nil, +// share: nil +// ), +// [1]: CKRecord( +// recordID: CKRecord.ID(1:modelAs/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), +// recordType: "modelAs", +// parent: nil, +// share: CKReference(recordID: CKRecord.ID(75140E4A-A949-427F-96D9-88DAC532844A/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) +// ) +// ] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// assertInlineSnapshot(of: otherSycnEngine.container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index d765717a..acb2239b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -21,9 +21,18 @@ extension BaseCloudKitTests { AFTER DELETE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsUpdatingRecord()) BEGIN SELECT sqlitedata_icloud_didDelete("old"."recordName", coalesce("old"."lastKnownServerRecord", ( - SELECT "sqlitedata_icloud_metadata"."lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" IS "old"."parentRecordName") + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) ))); END """, @@ -32,9 +41,18 @@ extension BaseCloudKitTests { AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsUpdatingRecord()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( - SELECT "sqlitedata_icloud_metadata"."lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" IS "new"."parentRecordName") + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) ))); END """, @@ -43,9 +61,18 @@ extension BaseCloudKitTests { AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsUpdatingRecord()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( - SELECT "sqlitedata_icloud_metadata"."lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" IS "new"."parentRecordName") + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) ))); END """, @@ -74,6 +101,30 @@ extension BaseCloudKitTests { END """, [6]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs" + AFTER DELETE ON "modelAs" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'modelAs'); + END + """, + [7]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs" + AFTER DELETE ON "modelBs" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'modelBs'); + END + """, + [8]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs" + AFTER DELETE ON "modelCs" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'modelCs'); + END + """, + [9]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -81,7 +132,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'parents'); END """, - [7]: """ + [10]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags" AFTER DELETE ON "reminderTags" FOR EACH ROW BEGIN @@ -89,7 +140,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminderTags'); END """, - [8]: """ + [11]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN @@ -97,7 +148,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminders'); END """, - [9]: """ + [12]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets" AFTER DELETE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -105,7 +156,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListAssets'); END """, - [10]: """ + [13]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" AFTER DELETE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -113,7 +164,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListPrivates'); END """, - [11]: """ + [14]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -121,7 +172,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); END """, - [12]: """ + [15]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags" AFTER DELETE ON "tags" FOR EACH ROW BEGIN @@ -129,7 +180,7 @@ extension BaseCloudKitTests { WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'tags'); END """, - [13]: """ + [16]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" AFTER INSERT ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -140,7 +191,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [14]: """ + [17]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -151,7 +202,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [15]: """ + [18]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -162,7 +213,40 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [16]: """ + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" + AFTER INSERT ON "modelAs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName") + SELECT 'modelAs', "new"."id" || ':' || 'modelAs', NULL AS "foreignKey" + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [20]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" + AFTER INSERT ON "modelBs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName") + SELECT 'modelBs', "new"."id" || ':' || 'modelBs', "new"."modelAID" || ':' || 'modelAs' AS "foreignKey" + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [21]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" + AFTER INSERT ON "modelCs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName") + SELECT 'modelCs', "new"."id" || ':' || 'modelCs', "new"."modelBID" || ':' || 'modelBs' AS "foreignKey" + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [22]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN @@ -173,7 +257,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [17]: """ + [23]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN @@ -184,7 +268,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [18]: """ + [24]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN @@ -195,7 +279,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [19]: """ + [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" AFTER INSERT ON "remindersListAssets" FOR EACH ROW BEGIN @@ -206,7 +290,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [20]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -217,7 +301,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [21]: """ + [27]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -228,7 +312,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [22]: """ + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" AFTER INSERT ON "tags" FOR EACH ROW BEGIN @@ -239,7 +323,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [23]: """ + [29]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" AFTER UPDATE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN @@ -250,7 +334,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [24]: """ + [30]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -261,7 +345,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [25]: """ + [31]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -272,7 +356,40 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [26]: """ + [32]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" + AFTER UPDATE ON "modelAs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName") + SELECT 'modelAs', "new"."id" || ':' || 'modelAs', NULL AS "foreignKey" + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [33]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" + AFTER UPDATE ON "modelBs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName") + SELECT 'modelBs', "new"."id" || ':' || 'modelBs', "new"."modelAID" || ':' || 'modelAs' AS "foreignKey" + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [34]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" + AFTER UPDATE ON "modelCs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordType", "recordName", "parentRecordName") + SELECT 'modelCs', "new"."id" || ':' || 'modelCs', "new"."modelBID" || ':' || 'modelBs' AS "foreignKey" + ON CONFLICT ("recordName") + DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [35]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -283,7 +400,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [27]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -294,7 +411,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [28]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -305,7 +422,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [29]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -316,7 +433,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [30]: """ + [39]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -327,7 +444,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [31]: """ + [40]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -338,7 +455,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [32]: """ + [41]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN @@ -349,7 +466,7 @@ extension BaseCloudKitTests { DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; END """, - [33]: """ + [42]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" BEFORE DELETE ON "parents" FOR EACH ROW BEGIN @@ -358,7 +475,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [34]: """ + [43]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" BEFORE UPDATE ON "parents" FOR EACH ROW BEGIN @@ -367,7 +484,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [35]: """ + [44]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -376,7 +493,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [36]: """ + [45]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -385,7 +502,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [37]: """ + [46]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -394,7 +511,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [38]: """ + [47]: """ CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -403,7 +520,7 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [39]: """ + [48]: """ CREATE TRIGGER "sqlitedata_icloud_localUsers_belongsTo_localUsers_onDeleteCascade" AFTER DELETE ON "localUsers" FOR EACH ROW BEGIN @@ -411,7 +528,23 @@ extension BaseCloudKitTests { WHERE "parentID" = "old"."id"; END """, - [40]: """ + [49]: """ + CREATE TRIGGER "sqlitedata_icloud_modelBs_belongsTo_modelAs_onDeleteCascade" + AFTER DELETE ON "modelAs" + FOR EACH ROW BEGIN + DELETE FROM "modelBs" + WHERE "modelAID" = "old"."id"; + END + """, + [50]: """ + CREATE TRIGGER "sqlitedata_icloud_modelCs_belongsTo_modelBs_onDeleteCascade" + AFTER DELETE ON "modelBs" + FOR EACH ROW BEGIN + DELETE FROM "modelCs" + WHERE "modelBID" = "old"."id"; + END + """, + [51]: """ CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_reminders_onDeleteCascade" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN @@ -419,7 +552,7 @@ extension BaseCloudKitTests { WHERE "reminderID" = "old"."id"; END """, - [41]: """ + [52]: """ CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_tags_onDeleteCascade" AFTER DELETE ON "tags" FOR EACH ROW BEGIN @@ -427,7 +560,7 @@ extension BaseCloudKitTests { WHERE "tagID" = "old"."id"; END """, - [42]: """ + [53]: """ CREATE TRIGGER "sqlitedata_icloud_remindersListAssets_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -435,7 +568,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [43]: """ + [54]: """ CREATE TRIGGER "sqlitedata_icloud_remindersListPrivates_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -443,7 +576,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [44]: """ + [55]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -451,7 +584,7 @@ extension BaseCloudKitTests { WHERE "remindersListID" = "old"."id"; END """, - [45]: """ + [56]: """ CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 55767287..af8ed408 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,7 @@ import SnapshotTesting import Testing @Suite( - .snapshots(record: .missing), + .snapshots(record: .failed), .dependencies { $0.date.now = Date(timeIntervalSince1970: 0) $0.dataManager = InMemoryDataManager() @@ -54,6 +54,9 @@ class BaseCloudKitTests: @unchecked Sendable { ChildWithOnDeleteRestrict.self, ChildWithOnDeleteSetNull.self, ChildWithOnDeleteSetDefault.self, + ModelA.self, + ModelB.self, + ModelC.self, ], privateTables: [ RemindersListPrivate.self diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index d6ecc74f..bcf8b15e 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -477,6 +477,7 @@ extension DependencyValues { } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func comparePendingRecordZoneChange( _ lhs: CKSyncEngine.PendingRecordZoneChange, _ rhs: CKSyncEngine.PendingRecordZoneChange @@ -494,6 +495,7 @@ private func comparePendingRecordZoneChange( } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func comparePendingDatabaseChange( _ lhs: CKSyncEngine.PendingDatabaseChange, _ rhs: CKSyncEngine.PendingDatabaseChange diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 05994798..cf7c9890 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -53,6 +53,20 @@ import SharingGRDB var name = "" var parentID: LocalUser.ID? } +@Table struct ModelA: Identifiable { + let id: UUID + var count = 0 +} +@Table struct ModelB: Identifiable { + let id: UUID + var isOn = false + var modelAID: ModelA.ID +} +@Table struct ModelC: Identifiable { + let id: UUID + var title = "" + var modelBID: ModelB.ID +} @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func database(containerIdentifier: String) throws -> DatabasePool { @@ -167,6 +181,29 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ ) .execute(db) + try #sql(""" + CREATE TABLE "modelAs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "count" INTEGER NOT NULL + ) + """) + .execute(db) + try #sql(""" + CREATE TABLE "modelBs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "isOn" INTEGER NOT NULL, + "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE + ) + """) + .execute(db) + try #sql(""" + CREATE TABLE "modelCs" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL, + "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE + ) + """) + .execute(db) } return database } From ea1e5972c027923951876fecce8721bed9bee276 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 11:17:22 -0700 Subject: [PATCH 340/581] wip --- .../CloudKitTests/SharingTests.swift | 77 ------------------- 1 file changed, 77 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 37f2164f..bf356020 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -251,83 +251,6 @@ extension BaseCloudKitTests { """ } } - -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func shareeCreatesMultipleRecords() async throws { -// let otherSycnEngine = try await withDependencies { -// $0.defaultOwnerName = "other-owner" -// } operation: { -// try await SyncEngine( -// container: MockCloudContainer( -// containerIdentifier: testContainerIdentifier, -// privateCloudDatabase: MockCloudDatabase(databaseScope: .private), -// sharedCloudDatabase: syncEngine.shared.database -// ), -// userDatabase: self.userDatabase, -// metadatabaseURL: URL.metadatabase(containerIdentifier: testContainerIdentifier), -// tables: [ -// ModelA.self, -// ModelB.self, -// ModelC.self, -// ] -// ) -// } -// -// let rootModel = ModelA(id: UUID(1)) -// try await userDatabase.userWrite { db in -// try db.seed { -// rootModel -// ModelB(id: UUID(1), modelAID: UUID(1)) -// ModelC(id: UUID(1), modelBID: UUID(1)) -// } -// } -// -// await syncEngine.processBatch() -// -// let share = try await syncEngine.share(record: rootModel) { _ in } -// -// assertInlineSnapshot(of: syncEngine.container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [ -// [0]: CKRecord( -// recordID: CKRecord.ID(75140E4A-A949-427F-96D9-88DAC532844A/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), -// recordType: "cloudkit.share", -// parent: nil, -// share: nil -// ), -// [1]: CKRecord( -// recordID: CKRecord.ID(1:modelAs/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), -// recordType: "modelAs", -// parent: nil, -// share: CKReference(recordID: CKRecord.ID(75140E4A-A949-427F-96D9-88DAC532844A/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) -// ) -// ] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// assertInlineSnapshot(of: otherSycnEngine.container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// } } } From 8676ae4f523e6b9cfc9d9b1938649dab8bf912ac Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 11:19:00 -0700 Subject: [PATCH 341/581] wip --- .../SharingGRDBCore/CloudKit/Triggers.swift | 82 ++++++++----------- 1 file changed, 33 insertions(+), 49 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 01b81b9d..213e70e3 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -111,55 +111,6 @@ extension SyncMetadata { ) } - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -func rootServerRecord( - recordName: some QueryExpression -) -> some QueryExpression { - With { - SyncMetadata - .find(recordName) - .select { - SyncMetadata.AncestorMetadata.Columns( - recordName: $0.recordName, - parentRecordName: $0.parentRecordName, - lastKnownServerRecord: $0.lastKnownServerRecord - ) - } - .union( - all: true, - SyncMetadata - .select { - SyncMetadata.AncestorMetadata.Columns( - recordName: $0.recordName, - parentRecordName: $0.parentRecordName, - lastKnownServerRecord: $0.lastKnownServerRecord - ) - } - .join(SyncMetadata.AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) } - ) - } query: { - SyncMetadata.AncestorMetadata - .select(\.lastKnownServerRecord) - .where { $0.parentRecordName.is(nil) } - } -} - -/* - WITH RECURSIVE ancestry(recordName, parentRecordName) AS ( - SELECT recordName, parentRecordName FROM sqlitedata_icloud_metadata WHERE recordName = 'fadbb91c-4565-4292-aa6e-579957f82371:modelCs' - UNION ALL - SELECT u.recordName, u.parentRecordName FROM sqlitedata_icloud_metadata u - JOIN ancestry a ON u.recordName = a.parentRecordName - ) - SELECT recordName FROM ancestry - WHERE parentRecordName IS NULL; - */ - - - - - // TODO: can we remove a layer of didUpdate/didDelete? extension QueryExpression where Self == SQLQueryExpression<()> { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -208,3 +159,36 @@ extension QueryExpression where Self == SQLQueryExpression<()> { private func isUpdatingWithServerRecord() -> SQLQueryExpression { SQLQueryExpression("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private func rootServerRecord( + recordName: some QueryExpression +) -> some QueryExpression { + With { + SyncMetadata + .find(recordName) + .select { + SyncMetadata.AncestorMetadata.Columns( + recordName: $0.recordName, + parentRecordName: $0.parentRecordName, + lastKnownServerRecord: $0.lastKnownServerRecord + ) + } + .union( + all: true, + SyncMetadata + .select { + SyncMetadata.AncestorMetadata.Columns( + recordName: $0.recordName, + parentRecordName: $0.parentRecordName, + lastKnownServerRecord: $0.lastKnownServerRecord + ) + } + .join(SyncMetadata.AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) } + ) + } query: { + SyncMetadata.AncestorMetadata + .select(\.lastKnownServerRecord) + .where { $0.parentRecordName.is(nil) } + } +} From 211ffe28dcf13639161cdabed59c7c7f483bc8a3 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 11:48:11 -0700 Subject: [PATCH 342/581] wrote test --- .../CloudKitTests/CloudKitTests.swift | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index e8d98a1c..dc4f894c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -451,6 +451,51 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerSendsRecordWithNoChanges() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: UUID(1), title: "Personal") + } + } + await syncEngine.processBatch() + + try await withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(UUID(1)).update { $0.title = "My stuff" }.execute(db) + } + } + + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) + await syncEngine.modifyRecords(scope: .private, saving: [record]) + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "My stuff" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteServerRecordUpdateWithOldRecord() async throws { try await userDatabase.userWrite { db in From 5cde48e443e2755e80e9b83163beb8a9610e8a99 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 12:01:16 -0700 Subject: [PATCH 343/581] wip --- Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index dc4f894c..f0352203 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -539,7 +539,6 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) record.encryptedValues["title"] = "Work" - let serverModificationDate = userModificationDate.addingTimeInterval(-60.0) // NB: Manually setting '_recordChangeTag' simulates another device saving a record. record._recordChangeTag = UUID().uuidString await syncEngine.modifyRecords(scope: .private, saving: [record]) From 8a28bd19f12e8c9a2d587f0d2ee54e12c06b9155 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 10 Jul 2025 16:20:56 -0700 Subject: [PATCH 344/581] Remodel Metadata This commit updates the sync metadata table to hold onto primary keys and table names separately and uses generated columns to produce the record name sent to iCloud. We will now require that table names do not include a `:` character and validate this at startup, but this change will put us in a better position to support primary keys other than UUID. --- .../CloudKit/CloudKitSharing.swift | 8 +- .../CloudKit/Metadatabase.swift | 12 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 182 ++++++++++-------- .../SyncMetadata+MacroExpansion.swift | 165 +++++++--------- .../CloudKit/SyncMetadata.swift | 119 +++++------- .../SharingGRDBCore/CloudKit/Triggers.swift | 46 +++-- .../CloudKitTests/CloudKitTests.swift | 12 +- .../FetchRecordZoneChangesTests.swift | 8 +- .../CloudKitTests/MetadataTests.swift | 28 +-- .../CloudKitTests/NewTableSyncTests.swift | 23 +-- .../NextRecordZoneChangeBatchTests.swift | 8 +- .../SyncEngineValidationTests.swift | 37 +++- .../CloudKitTests/TriggerTests.swift | 180 ++++++++--------- .../Internal/CloudKitTestHelpers.swift | 2 +- 14 files changed, 430 insertions(+), 400 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index c01d0ed3..1eb56b66 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -43,11 +43,11 @@ extension SyncEngine { guard foreignKeys.isEmpty else { throw RecordMustBeRoot() } - let recordName = SyncMetadata.RecordName(record: record) + let recordName = record.recordName let metadata = try await metadatabase.read { db in try SyncMetadata - .find(recordName) + .where { $0.recordName.eq(recordName) } .fetchOne(db) } ?? nil @@ -63,7 +63,7 @@ extension SyncEngine { ?? CKRecord( recordType: metadata.recordType, recordID: CKRecord.ID( - recordName: metadata.recordName.rawValue, + recordName: metadata.recordName, zoneID: Self.defaultZone.zoneID ) ) @@ -93,7 +93,7 @@ extension SyncEngine { ) try await userDatabase.write { db in try SyncMetadata - .find(recordName) + .where { $0.recordName.eq(recordName) } .update { $0.share = sharedRecord } .execute(db) } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index d9e02863..caa1b815 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -36,13 +36,19 @@ func defaultMetadatabase( try SQLQueryExpression( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( + "recordPrimaryKey" TEXT NOT NULL, "recordType" TEXT NOT NULL, - "recordName" TEXT NOT NULL PRIMARY KEY, - "parentRecordName" TEXT, + "recordName" TEXT NOT NULL AS ("recordPrimaryKey" || ':' || "recordType"), + "parentRecordPrimaryKey" TEXT, + "parentRecordType" TEXT, + "parentRecordName" TEXT AS ("parentRecordPrimaryKey" || ':' || "parentRecordType"), "lastKnownServerRecord" BLOB, "_lastKnownServerRecordAllFields" BLOB, "share" BLOB, - "userModificationDate" TEXT NOT NULL DEFAULT (\(.datetime())) + "userModificationDate" TEXT NOT NULL DEFAULT (\(.datetime())), + + PRIMARY KEY ("recordPrimaryKey", "recordType"), + UNIQUE ("recordName") ) STRICT """ ) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 0b7976d8..790f8cf7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -258,32 +258,24 @@ : nil func open>(_: T.Type) throws { + let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = + parentForeignKey + .map { ("\(T.self).\(quote: $0.from)", "\(bind: $0.table)") } + ?? ("NULL", "NULL") try SyncMetadata.insert { columns in ( + columns.recordPrimaryKey, columns.recordType, - columns.recordName, - columns.parentRecordName + columns.parentRecordPrimaryKey, + columns.parentRecordType ) } select: { T.select { columns in ( - SQLQueryExpression("\(quote: T.tableName, delimiter: .text)"), - SQLQueryExpression( - """ - \(columns.primaryKey) || ':' || \(quote: T.tableName, delimiter: .text) - """, - as: SyncMetadata.RecordName.self - ), - parentForeignKey.map { parentForeignKey in - SQLQueryExpression( - """ - \(T.self).\(quote: parentForeignKey.from, delimiter: .identifier) \ - || ':' || \(quote: parentForeignKey.table, delimiter: .text) - """, - as: SyncMetadata.RecordName?.self - ) - } - ?? SQLQueryExpression("NULL") + SQLQueryExpression("\(columns.primaryKey)"), + T.tableName, + SQLQueryExpression(parentRecordPrimaryKey), + SQLQueryExpression(parentRecordType) ) } } @@ -381,7 +373,7 @@ } #endif - func didUpdate(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { + func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { let zoneID = zoneID ?? Self.defaultZone.zoneID let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -390,7 +382,7 @@ pendingRecordZoneChanges: [ .saveRecord( CKRecord.ID( - recordName: recordName.rawValue, + recordName: recordName, zoneID: zoneID ) ) @@ -398,7 +390,7 @@ ) } - func didDelete(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { + func didDelete(recordName: String, zoneID: CKRecordZone.ID?) { print("didDelete", recordName) let zoneID = zoneID ?? Self.defaultZone.zoneID let syncEngine = self.syncEngines.withValue { @@ -408,7 +400,7 @@ pendingRecordZoneChanges: [ .deleteRecord( CKRecord.ID( - recordName: recordName.rawValue, + recordName: recordName, zoneID: zoneID ) ) @@ -548,10 +540,10 @@ return true case (.deleteRecord(let lhs), .deleteRecord(let rhs)): guard - let lhsRecordName = SyncMetadata.RecordName(rawValue: lhs.recordName), - let lhsIndex = tablesByOrder[lhsRecordName.recordType], - let rhsRecordName = SyncMetadata.RecordName(rawValue: rhs.recordName), - let rhsIndex = tablesByOrder[rhsRecordName.recordType] + let lhsRecordType = lhs.recordType, + let lhsIndex = tablesByOrder[lhsRecordType], + let rhsRecordType = rhs.recordType, + let rhsIndex = tablesByOrder[rhsRecordType] else { return true } return lhsIndex > rhsIndex case (.saveRecord, .deleteRecord): @@ -616,13 +608,12 @@ #endif guard - let recordName = SyncMetadata.RecordName(recordID: recordID), let (metadata, allFields) = await withErrorReporting( .sqliteDataCloudKitFailure, catching: { try await metadatabase.read { db in try SyncMetadata - .find(recordName) + .where { $0.recordName.eq(recordID.recordName) } .select { ($0, $0._lastKnownServerRecordAllFields) } .fetchOne(db) } @@ -643,7 +634,11 @@ let row = withErrorReporting { try userDatabase.read { db in - try T.find(recordName.id).fetchOne(db) + try T + .where { + SQLQueryExpression("\($0.primaryKey) = \(bind: metadata.recordPrimaryKey)") + } + .fetchOne(db) } } ?? nil @@ -660,16 +655,19 @@ recordType: metadata.recordType, recordID: recordID ) - record.parent = metadata.parentRecordName.flatMap { parentRecordName in - guard !privateTables.contains(where: { $0.tableName == parentRecordName.recordType }) - else { return nil } - return CKRecord.Reference( + if let parentRecordName = metadata.parentRecordName, + let parentRecordType = metadata.parentRecordType, + !privateTables.contains(where: { $0.tableName == parentRecordType }) + { + record.parent = CKRecord.Reference( recordID: CKRecord.ID( - recordName: parentRecordName.rawValue, + recordName: parentRecordName, zoneID: record.recordID.zoneID ), action: .none ) + } else { + record.parent = nil } record.update( @@ -697,7 +695,7 @@ for table in tables { withErrorReporting(.sqliteDataCloudKitFailure) { let recordNames = try userDatabase.read { db in - func open>(_: T.Type) throws -> [SyncMetadata.RecordName] { + func open>(_: T.Type) throws -> [String] { try T .select(\.primaryKey) .fetchAll(db) @@ -709,7 +707,7 @@ pendingRecordZoneChanges: recordNames.map { .saveRecord( CKRecord.ID( - recordName: $0.rawValue, + recordName: $0, zoneID: Self.defaultZone.zoneID ) ) @@ -795,14 +793,17 @@ // TODO: Group by recordType and delete in batches for (recordID, recordType) in deletions { if let table = tablesByName[recordType] { - guard let recordName = SyncMetadata.RecordName(recordID: recordID) - else { - continue - } func open>(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in - try T.find(recordName.id) + try T + .where { + SQLQueryExpression("\($0.primaryKey)").eq( + SyncMetadata + .where { $0.recordName.eq(recordID.recordName) } + .select(\.recordPrimaryKey) + ) + } .delete() .execute(db) } @@ -845,16 +846,11 @@ } for failedRecordSave in failedRecordSaves { let failedRecord = failedRecordSave.record - guard let recordName = SyncMetadata.RecordName(rawValue: failedRecord.recordID.recordName) - else { - continue - } - func clearServerRecord() { withErrorReporting { try userDatabase.write { db in try SyncMetadata - .find(recordName) + .where { $0.recordName.eq(failedRecord.recordID.recordName) } .update { $0.setLastKnownServerRecord(nil) } .execute(db) } @@ -912,14 +908,9 @@ guard let rootRecord = metadata.rootRecord else { return } - guard let recordName = SyncMetadata.RecordName(recordID: rootRecord.recordID) - else { - return - } - try await userDatabase.write { db in try SyncMetadata - .find(recordName) + .where { $0.recordName.eq(rootRecord.recordID.recordName) } .update { $0.share = share } .execute(db) } @@ -935,7 +926,8 @@ .first(where: { $0.share?.recordID == recordID }) ?? nil guard let metadata else { return } - try SyncMetadata.find(metadata.recordName) + try SyncMetadata + .where { $0.recordName.eq(metadata.recordName) } .update { $0.share = nil } .execute(db) } @@ -956,12 +948,10 @@ ) return } - guard let recordName = SyncMetadata.RecordName(recordID: serverRecord.recordID) - else { return } let result = try metadatabase.read { db in try SyncMetadata - .find(recordName) + .where { $0.recordName.eq(serverRecord.recordID.recordName) } .select { ($0, $0._lastKnownServerRecordAllFields) } .fetchOne(db) } @@ -971,13 +961,17 @@ func open>(_: T.Type) throws { var columnNames = T.TableColumns.allColumns.map(\.name) - if let allFields { + if let metadata, let allFields { let row = try userDatabase.read { db in - try T.find(recordName.id).fetchOne(db) + try T.find(SQLQueryExpression("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) } guard let row else { - reportIssue("Local database record could not be found for '\(recordName.rawValue)'.") + reportIssue( + """ + Local database record could not be found for '\(serverRecord.recordID.recordName)'. + """ + ) return } serverRecord.update( @@ -1021,7 +1015,7 @@ try userDatabase.write { db in try SQLQueryExpression(query).execute(db) try SyncMetadata - .find(recordName) + .where { $0.recordName.eq(serverRecord.recordID.recordName) } .update { $0.setLastKnownServerRecord(serverRecord) } .execute(db) } @@ -1031,17 +1025,13 @@ } private func refreshLastKnownServerRecord(_ record: CKRecord) async { - guard let recordName = SyncMetadata.RecordName(recordID: record.recordID) - else { - return - } - let metadata = await metadataFor(recordName: recordName) + let metadata = await metadataFor(recordName: record.recordID.recordName) func updateLastKnownServerRecord() { withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in try SyncMetadata - .find(recordName) + .where { $0.recordName.eq(record.recordID.recordName) } .update { $0.setLastKnownServerRecord(record) } .execute(db) } @@ -1057,16 +1047,43 @@ } } - private func metadataFor(recordName: SyncMetadata.RecordName) async -> SyncMetadata? { + private func metadataFor(recordName: String) async -> SyncMetadata? { await withErrorReporting(.sqliteDataCloudKitFailure) { try await metadatabase.read { db in - try SyncMetadata.find(recordName).fetchOne(db) + try SyncMetadata.where { $0.recordName.eq(recordName) }.fetchOne(db) } } ?? nil } } + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKSyncEngine.PendingRecordZoneChange { + var id: CKRecord.ID? { + switch self { + case .saveRecord(let id): + return id + case .deleteRecord(let id): + return id + @unknown default: + return nil + } + } + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKRecord.ID { + var recordType: String? { + guard + let i = recordName.utf8.lastIndex(of: .init(ascii: ":")), + let j = recordName.utf8.index(i, offsetBy: 1, limitedBy: recordName.utf8.endIndex) + else { return nil } + let recordTypeBytes = recordName.utf8[j...] + guard !recordTypeBytes.isEmpty else { return nil } + return String(Substring(recordTypeBytes)) + } + } + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { @@ -1105,7 +1122,7 @@ private convenience init( _ name: String, - function: @escaping @Sendable (SyncMetadata.RecordName, CKRecordZone.ID?) -> Void + function: @escaping @Sendable (String, CKRecordZone.ID?) -> Void ) { self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 2) { arguments in guard @@ -1113,10 +1130,6 @@ else { return nil } - guard let recordName = SyncMetadata.RecordName(rawValue: recordName) - else { - return nil - } let zoneID = try Data.fromDatabaseValue(arguments[1]).flatMap { let coder = try NSKeyedUnarchiver(forReadingFrom: $0) coder.requiresSecureCoding = true @@ -1229,6 +1242,11 @@ userDatabase: UserDatabase ) throws { let tableNames = Set(tables.map { $0.tableName }) + for tableName in tableNames { + if tableName.contains(":") { + throw InvalidTableName(tableName: tableName) + } + } try userDatabase.read { db in let triggers = try SQLQueryExpression( """ @@ -1288,13 +1306,23 @@ } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct InvalidTableName: LocalizedError { + let tableName: String + public var localizedDescription: String { + """ + Table name \(tableName.debugDescription) contains invalid character ':'. + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public struct InvalidUserTriggers: LocalizedError { let triggers: [String] public var localizedDescription: String { """ - Triggers must include 'WHEN NOT \(DatabaseFunction.syncEngineIsUpdatingRecord.name)()' \ - clause: \(triggers.map { "'\($0)'" }.joined(separator: ", ")) + Triggers must include '\(DatabaseFunction.syncEngineIsUpdatingRecord.name)()' check: \ + \(triggers.map { "'\($0)'" }.joined(separator: ", ")) """ } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 3fc4f1f4..287e2bc1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -1,58 +1,85 @@ #if canImport(CloudKit) -import CloudKit + import CloudKit -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - public struct TableColumns: StructuredQueriesCore.TableDefinition, PrimaryKeyedTableDefinition { - public typealias QueryValue = SyncMetadata - public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) - public var primaryKey: StructuredQueriesCore.TableColumn { - self.recordName - } - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] - } - } - - public struct Draft: StructuredQueriesCore.TableDraft { - public typealias PrimaryTable = SyncMetadata - public var recordType: String - public var recordName: RecordName? - public var parentRecordName: RecordName? - public var lastKnownServerRecord: CKRecord? - public var share: CKShare? - public var userModificationDate: Date + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = Draft - public let recordType = StructuredQueriesCore.TableColumn("recordType", keyPath: \QueryValue.recordType) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let share = StructuredQueriesCore.TableColumn("share", keyPath: \QueryValue.share) - public let userModificationDate = StructuredQueriesCore.TableColumn("userModificationDate", keyPath: \QueryValue.userModificationDate) + public typealias QueryValue = SyncMetadata + public let recordPrimaryKey = StructuredQueriesCore.TableColumn( + "recordPrimaryKey", + keyPath: \QueryValue.recordPrimaryKey + ) + public let recordType = StructuredQueriesCore.TableColumn( + "recordType", + keyPath: \QueryValue.recordType + ) + public var recordName: some StructuredQueriesCore.QueryExpression { + StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + } + public let parentRecordPrimaryKey = StructuredQueriesCore.TableColumn( + "parentRecordPrimaryKey", + keyPath: \QueryValue.parentRecordPrimaryKey + ) + public let parentRecordType = StructuredQueriesCore.TableColumn( + "parentRecordType", + keyPath: \QueryValue.parentRecordType + ) + public var parentRecordName: some StructuredQueriesCore.QueryExpression { + StructuredQueriesCore.TableColumn( + "parentRecordName", + keyPath: \QueryValue.parentRecordName + ) + } + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.SystemFieldsRepresentation + >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let share = StructuredQueriesCore.TableColumn< + QueryValue, CKShare?.SystemFieldsRepresentation + >("share", keyPath: \QueryValue.share) + public let userModificationDate = StructuredQueriesCore.TableColumn( + "userModificationDate", + keyPath: \QueryValue.userModificationDate + ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, QueryValue.columns.userModificationDate] + [ + QueryValue.columns.recordPrimaryKey, QueryValue.columns.recordType, + QueryValue.columns.parentRecordPrimaryKey, QueryValue.columns.parentRecordType, + QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, + QueryValue.columns.userModificationDate, + ] + } + public var queryFragment: QueryFragment { + "\(self.recordPrimaryKey), \(self.recordType), \(self.recordName), \(self.parentRecordPrimaryKey), \(self.parentRecordType), \(self.parentRecordName), \(self.lastKnownServerRecord), \(self.share), \(self.userModificationDate)" } } - public static let columns = TableColumns() - - public static let tableName = SyncMetadata.tableName + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata: StructuredQueriesCore.Table { + public static let columns = TableColumns() + public static let tableName = "sqlitedata_icloud_metadata" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let recordPrimaryKey = try decoder.decode(String.self) let recordType = try decoder.decode(String.self) - self.recordName = try decoder.decode(RecordName.self) - self.parentRecordName = try decoder.decode(RecordName.self) + let recordName = try decoder.decode(String.self) + self.parentRecordPrimaryKey = try decoder.decode(String.self) + self.parentRecordType = try decoder.decode(String.self) + self.parentRecordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) let userModificationDate = try decoder.decode(Date.self) + guard let recordPrimaryKey else { + throw QueryDecodingError.missingRequiredColumn + } guard let recordType else { throw QueryDecodingError.missingRequiredColumn } + guard let recordName else { + throw QueryDecodingError.missingRequiredColumn + } guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } @@ -62,68 +89,12 @@ extension SyncMetadata { guard let userModificationDate else { throw QueryDecodingError.missingRequiredColumn } - self.recordType = recordType - self.lastKnownServerRecord = lastKnownServerRecord - self.share = share - self.userModificationDate = userModificationDate - } - - public init(_ other: SyncMetadata) { - self.recordType = other.recordType - self.recordName = other.recordName - self.parentRecordName = other.parentRecordName - self.lastKnownServerRecord = other.lastKnownServerRecord - self.share = other.share - self.userModificationDate = other.userModificationDate - } - public init( - recordType: String, - recordName: RecordName? = nil, - parentRecordName: RecordName? = nil, - lastKnownServerRecord: CKRecord? = nil, - share: CKShare? = nil, - userModificationDate: Date - ) { + self.recordPrimaryKey = recordPrimaryKey self.recordType = recordType self.recordName = recordName - self.parentRecordName = parentRecordName self.lastKnownServerRecord = lastKnownServerRecord self.share = share self.userModificationDate = userModificationDate } } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata: StructuredQueriesCore.Table, PrimaryKeyedTable { - public static let columns = TableColumns() - public static let tableName = "sqlitedata_icloud_metadata" - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let recordType = try decoder.decode(String.self) - let recordName = try decoder.decode(RecordName.self) - self.parentRecordName = try decoder.decode(RecordName.self) - let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) - let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) - let userModificationDate = try decoder.decode(Date.self) - guard let recordType else { - throw QueryDecodingError.missingRequiredColumn - } - guard let recordName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let lastKnownServerRecord else { - throw QueryDecodingError.missingRequiredColumn - } - guard let share else { - throw QueryDecodingError.missingRequiredColumn - } - guard let userModificationDate else { - throw QueryDecodingError.missingRequiredColumn - } - self.recordType = recordType - self.recordName = recordName - self.lastKnownServerRecord = lastKnownServerRecord - self.share = share - self.userModificationDate = userModificationDate - } -} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index f45420dc..a2b73a39 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -12,29 +12,38 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") public struct SyncMetadata: Hashable, Sendable { - /// The type of the record synchronized, i.e. the table name. + /// The unique identifier of the record synchronized. + public var recordPrimaryKey: String + + /// The type of the record synchronized, _i.e._ its table name. public var recordType: String /// The name of the record synchronized. /// /// This field encodes both the table name and primary key of the record synchronized in - /// the format "tableName:primaryKey", for example: + /// the format "primaryKey:tableName", for example: /// /// ```swift /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" /// ``` - // @Column(primaryKey: true) - public var recordName: RecordName + // @Column(generated: .virtual) + public let recordName: String + + /// The unique identifier of this record's parent, if any. + public var parentRecordPrimaryKey: String? - /// The name of the record that this record belongs to. + /// The type of this record's parent, _i.e._ its table name, if any. + public var parentRecordType: String? + + /// The name of this record's parent, if any. /// /// This field encodes both the table name and primary key of the parent record in the format - /// "tableName:primaryKey", for example: + /// "primaryKey:tableName", for example: /// /// ```swift /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" /// ``` - public var parentRecordName: RecordName? + public let parentRecordName: String? /// The last known `CKRecord` received from the server. // @Column(as: CKRecord?.SystemFieldsRepresentation.self) @@ -48,15 +57,21 @@ public struct SyncMetadata: Hashable, Sendable { public var userModificationDate: Date package init( + recordPrimaryKey: String, recordType: String, - recordName: RecordName, - parentRecordName: RecordName? = nil, + recordName: String, + parentRecordPrimaryKey: String? = nil, + parentRecordType: String? = nil, + parentRecordName: String?, lastKnownServerRecord: CKRecord? = nil, share: CKShare? = nil, userModificationDate: Date ) { + self.recordPrimaryKey = recordPrimaryKey self.recordType = recordType self.recordName = recordName + self.parentRecordPrimaryKey = parentRecordPrimaryKey + self.parentRecordType = parentRecordType self.parentRecordName = parentRecordName self.lastKnownServerRecord = lastKnownServerRecord self.share = share @@ -64,64 +79,8 @@ public struct SyncMetadata: Hashable, Sendable { } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - public struct RecordName: RawRepresentable, Sendable, Hashable, QueryBindable { - public var recordType: String - public var id: UUID - - public init>(_ table: T.Type, id: UUID) { - recordType = T.tableName - self.id = id - } - - public init?(rawValue: String) { - guard - let colonIndex = rawValue.firstIndex(of: ":"), - let id = UUID(uuidString: String(rawValue[rawValue.startIndex..>(record: T) { - recordType = T.tableName - id = record[keyPath: T.columns.primaryKey.keyPath] - } - - public init?(recordID: CKRecord.ID) { - self.init(rawValue: recordID.recordName) - } - - public var rawValue: String { - "\(id.uuidString.lowercased()):\(recordType)" - } - } -} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata.TableColumns { - public var parentRecordPrimaryKey: some QueryExpression { - SQLQueryExpression("substr(\(parentRecordName), 1, 36)") - } - - public var recordPrimaryKey: some QueryExpression { - SQLQueryExpression("substr(\(recordName), 1, 36)") - } - - public var parentRecordType: some QueryExpression { - SQLQueryExpression("substr(\(parentRecordName), 38)") - } - package var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< SyncMetadata, CKRecord?.AllFieldsRepresentation @@ -136,10 +95,26 @@ extension SyncMetadata.TableColumns { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { fileprivate var _lastKnownServerRecordAllFields: CKRecord? { - fatalError(""" + fatalError( + """ Never invoke this directly. Use 'SyncMetadata.TableColumns._lastKnownServerRecordAllFields' \ instead. - """) + """ + ) + } + + package static func find( + _ primaryKey: T.PrimaryKey.QueryOutput, + table _: T.Type, + ) -> Where { + Self.where { + SQLQueryExpression( + """ + \($0.recordPrimaryKey) = \(T.PrimaryKey(queryOutput: primaryKey)) \ + AND \($0.recordType) = \(bind: T.tableName) + """ + ) + } } } @@ -148,14 +123,18 @@ extension PrimaryKeyedTable { /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. /// /// - Parameter id: The ID of the record. - public static func recordName(for id: UUID) -> SyncMetadata.RecordName { - SyncMetadata.RecordName(Self.self, id: id) + public static func recordName(for id: UUID) -> String { + "\(id.uuidString.lowercased()):\(tableName)" + } + + var recordName: String { + Self.recordName(for: self[keyPath: Self.columns.primaryKey.keyPath]) } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTableDefinition { - public var recordName: some QueryExpression { + public var recordName: some QueryExpression { SQLQueryExpression(" \(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 315ee8b8..97c6e5ac 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -33,7 +33,10 @@ extension PrimaryKeyedTable { ifNotExists: true, after: .delete { old in SyncMetadata - .find(old.recordName) + .where { + $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) + && $0.recordType.eq(tableName) + } .delete() } ) @@ -46,23 +49,24 @@ extension SyncMetadata { new: TemporaryTrigger.Operation.New, parentForeignKey: ForeignKey?, ) -> some StructuredQueriesCore.Statement { - let parentForeignKey = - parentForeignKey.map { - #""new"."\#($0.from)" || ':' || '\#($0.table)'"# - } ?? "NULL" + let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = + parentForeignKey + .map { (#""new".\#(quote: $0.from)"#, "\(bind: $0.table)") } + ?? ("NULL", "NULL") return insert { - ($0.recordType, $0.recordName, $0.parentRecordName) + ($0.recordPrimaryKey, $0.recordType, $0.parentRecordPrimaryKey, $0.parentRecordType) } select: { Values( + SQLQueryExpression("\(new.primaryKey)"), T.tableName, - new.recordName, - SQLQueryExpression(#"\#(raw: parentForeignKey) AS "foreignKey""#) + SQLQueryExpression(parentRecordPrimaryKey), + SQLQueryExpression(parentRecordType) ) } onConflict: { - $0.recordName + ($0.recordPrimaryKey, $0.recordType) } doUpdate: { - $0.recordName = SQLQueryExpression(#""excluded"."recordName""#) - $0.parentRecordName = SQLQueryExpression(#""excluded"."parentRecordName""#) + $0.parentRecordPrimaryKey = SQLQueryExpression(#""excluded"."parentRecordPrimaryKey""#) + $0.parentRecordType = SQLQueryExpression(#""excluded"."parentRecordType""#) $0.userModificationDate = SQLQueryExpression(#""excluded"."userModificationDate""#) } } @@ -111,16 +115,24 @@ extension SyncMetadata { ) } +//extension StructuredQueriesCore.TableAlias.TableColumns { +// public subscript( +// dynamicMember keyPath: KeyPath +// ) -> Member { +// Base.columns[keyPath: keyPath] +// } +//} + extension QueryExpression where Self == SQLQueryExpression<()> { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) fileprivate static func didUpdate( _ new: StructuredQueriesCore.TableAlias.Operation._New>.TableColumns ) -> Self { .didUpdate( - recordName: new.recordName, + recordName: SQLQueryExpression(#""new"."recordName""#), lastKnownServerRecord: new.lastKnownServerRecord ?? SyncMetadata - .where { $0.recordName.is(new.parentRecordName) } + .where { $0.recordName.is(SQLQueryExpression(#""new"."parentRecordName""#)) } .select(\.lastKnownServerRecord) ) } @@ -132,17 +144,17 @@ extension QueryExpression where Self == SQLQueryExpression<()> { -> Self { .didDelete( - recordName: old.recordName, + recordName: SQLQueryExpression(#""old"."recordName""#), lastKnownServerRecord: old.lastKnownServerRecord ?? SyncMetadata - .where { $0.recordName.is(old.parentRecordName) } + .where { $0.recordName.is(SQLQueryExpression(#""old"."parentRecordName""#)) } .select(\.lastKnownServerRecord) ) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private static func didUpdate( - recordName: some QueryExpression, + recordName: some QueryExpression, lastKnownServerRecord: some QueryExpression ) -> Self { Self("\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(recordName), \(lastKnownServerRecord))") @@ -150,7 +162,7 @@ extension QueryExpression where Self == SQLQueryExpression<()> { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private static func didDelete( - recordName: some QueryExpression, + recordName: some QueryExpression, lastKnownServerRecord: some QueryExpression ) -> Self diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 14781160..db0ee7d4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -203,7 +203,7 @@ extension BaseCloudKitTests { let metadata = try await userDatabase.userRead { db in - try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) + try SyncMetadata.find(UUID(1), table: RemindersList.self).fetchOne(db) } #expect(metadata != nil) } @@ -373,7 +373,7 @@ extension BaseCloudKitTests { let userModificationDate = try #require( try await userDatabase.userRead { db in try SyncMetadata - .find(RemindersList.recordName(for: UUID(1))) + .find(UUID(1), table: RemindersList.self) .select(\.userModificationDate) .fetchOne(db) ?? nil } @@ -392,7 +392,7 @@ extension BaseCloudKitTests { let metadata = try #require( try await userDatabase.userRead { db in try SyncMetadata - .find(RemindersList.recordName(for: UUID(1))) + .find(UUID(1), table: RemindersList.self) .fetchOne(db) } ) @@ -457,7 +457,7 @@ extension BaseCloudKitTests { let userModificationDate = try #require( try await userDatabase.userRead { db in try SyncMetadata - .find(RemindersList.recordName(for: UUID(1))) + .find(UUID(1), table: RemindersList.self) .select(\.userModificationDate) .fetchOne(db) ?? nil } @@ -478,7 +478,7 @@ extension BaseCloudKitTests { let metadata = try #require( try await userDatabase.userRead { db in try SyncMetadata - .find(RemindersList.recordName(for: UUID(1))) + .find(UUID(1), table: RemindersList.self) .fetchOne(db) } ) @@ -549,7 +549,7 @@ extension BaseCloudKitTests { ) let metadata = try await userDatabase.userRead { db in try SyncMetadata - .find(RemindersList.recordName(for: UUID(1))) + .find(UUID(1), table: RemindersList.self) .fetchOne(db) } #expect(metadata == nil) diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index aa870cc3..f21cc01e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -152,7 +152,7 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(Reminder.recordName(for: UUID(1))).fetchOne(db) + try SyncMetadata.find(UUID(1), table: Reminder.self).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: UUID(2))) let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) @@ -187,7 +187,7 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(Reminder.recordName(for: UUID(1))).fetchOne(db) + try SyncMetadata.find(UUID(1), table: Reminder.self).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: UUID(2))) let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) @@ -240,7 +240,7 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(RemindersList.recordName(for: UUID(1))).fetchOne(db) + try SyncMetadata.find(UUID(1), table: RemindersList.self).fetchOne(db) ) #expect(metadata.recordName == RemindersList.recordName(for: UUID(1))) let remindersList = try #require(try RemindersList.find(UUID(1)).fetchOne(db)) @@ -333,7 +333,7 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(Reminder.recordName(for: UUID(1))).fetchOne(db) + try SyncMetadata.find(UUID(1), table: Reminder.self).fetchOne(db) ) #expect(metadata.recordName == Reminder.recordName(for: UUID(1))) #expect(metadata.parentRecordName == RemindersList.recordName(for: UUID(1))) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 5a6eb7ca..90dc361f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -66,7 +66,7 @@ extension BaseCloudKitTests { try await userDatabase.userRead { db in let reminderMetadata = try #require( try SyncMetadata - .find(Reminder.recordName(for: UUID(1))) + .where { $0.recordName.eq(Reminder.recordName(for: UUID(1))) } .fetchOne(db) ) #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(1))) @@ -75,17 +75,19 @@ extension BaseCloudKitTests { try await withDependencies { $0.date.now.addTimeInterval(60) } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)) - .update { $0.remindersListID = UUID(2) } - .execute(db) - let reminderMetadata = try #require( - try SyncMetadata - .find(Reminder.recordName(for: UUID(1))) - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(2))) - } + _ = try { + try userDatabase.userWrite { db in + try Reminder.find(UUID(1)) + .update { $0.remindersListID = UUID(2) } + .execute(db) + let reminderMetadata = try #require( + try SyncMetadata + .where { $0.recordName.eq(Reminder.recordName(for: UUID(1))) } + .fetchOne(db) + ) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(2))) + } + }() } await syncEngine.processBatch() @@ -271,7 +273,7 @@ extension BaseCloudKitTests { try await userDatabase.userRead { db in let reminderMetadata = try SyncMetadata - .where { $0.parentRecordPrimaryKey == UUID(1) } + .where { $0.parentRecordPrimaryKey.eq(UUID(1).uuidString.lowercased()) } .fetchAll(db) #expect( reminderMetadata.map(\.recordName) == [ diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 6b3c457f..37938816 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -60,21 +60,18 @@ extension BaseCloudKitTests { } let metadata = try await userDatabase.userRead { db in - try SyncMetadata.all.order(by: \.primaryKey).fetchAll(db) + try SyncMetadata.order(by: \.recordName).fetchAll(db) } assertInlineSnapshot(of: metadata, as: .customDump) { """ [ [0]: SyncMetadata( + recordPrimaryKey: "00000000-0000-0000-0000-000000000001", recordType: "reminders", - recordName: SyncMetadata.RecordName( - recordType: "reminders", - id: UUID(00000000-0000-0000-0000-000000000001) - ), - parentRecordName: SyncMetadata.RecordName( - recordType: "remindersLists", - id: UUID(00000000-0000-0000-0000-000000000001) - ), + recordName: "00000000-0000-0000-0000-000000000001:reminders", + parentRecordPrimaryKey: "00000000-0000-0000-0000-000000000001", + parentRecordType: "remindersLists", + parentRecordName: "00000000-0000-0000-0000-000000000001:remindersLists", lastKnownServerRecord: CKRecord( recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "reminders", @@ -85,11 +82,11 @@ extension BaseCloudKitTests { userModificationDate: Date(1970-01-01T00:00:00.000Z) ), [1]: SyncMetadata( + recordPrimaryKey: "00000000-0000-0000-0000-000000000001", recordType: "remindersLists", - recordName: SyncMetadata.RecordName( - recordType: "remindersLists", - id: UUID(00000000-0000-0000-0000-000000000001) - ), + recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + parentRecordPrimaryKey: nil, + parentRecordType: nil, parentRecordName: nil, lastKnownServerRecord: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 1022a49e..ac8f134a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -35,8 +35,10 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try SyncMetadata.insert { SyncMetadata( + recordPrimaryKey: UUID(1).uuidString.lowercased(), recordType: UnrecognizedTable.tableName, - recordName: SyncMetadata.RecordName(UnrecognizedTable.self, id: UUID(1)), + recordName: UnrecognizedTable.recordName(for: UUID(1)), + parentRecordName: nil, userModificationDate: .distantPast ) } @@ -64,8 +66,10 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try SyncMetadata.insert { SyncMetadata( + recordPrimaryKey: UUID(1).uuidString.lowercased(), recordType: RemindersList.tableName, - recordName: SyncMetadata.RecordName(RemindersList.self, id: UUID(1)), + recordName: RemindersList.recordName(for: UUID(1)), + parentRecordName: nil, userModificationDate: .distantPast ) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 3dce9f18..8e843122 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -7,10 +7,41 @@ import SnapshotTestingCustomDump import Testing extension BaseCloudKitTests { + @Table("invalid:table") + struct InvalidTable { + let id: UUID + } + @MainActor struct SyncEngineValidationTests { - @Test - func userTriggerValidation() async throws { + @Test func tableNameValidation() async throws { + let error = try #require( + await #expect(throws: InvalidTableName.self) { + var configuration = Configuration() + configuration.foreignKeysEnabled = false + let database = try DatabaseQueue(configuration: configuration) + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + metadatabaseURL: URL.temporaryDirectory.appending(path: UUID().uuidString), + tables: [InvalidTable.self] + ) + } + ) + #expect( + error.localizedDescription.hasPrefix( + """ + Table name "invalid:table" contains invalid character ':'. + """ + ) + ) + } + + @Test func userTriggerValidation() async throws { let error = try await #require( #expect(throws: InvalidUserTriggers.self) { var configuration = Configuration() @@ -63,7 +94,7 @@ extension BaseCloudKitTests { #expect( error.localizedDescription.hasPrefix( """ - Triggers must include 'WHEN NOT sqlitedata_icloud_syncEngineIsUpdatingRecord()' clause: \ + Triggers must include 'sqlitedata_icloud_syncEngineIsUpdatingRecord()' check: \ 'non_temporary_trigger', 'temporary_trigger' """ ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index d765717a..99a4a1a3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -54,7 +54,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteRestricts'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteRestricts')); END """, [4]: """ @@ -62,7 +62,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetDefaults'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); END """, [5]: """ @@ -70,7 +70,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'childWithOnDeleteSetNulls'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); END """, [6]: """ @@ -78,7 +78,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "parents" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'parents'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); END """, [7]: """ @@ -86,7 +86,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "reminderTags" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminderTags'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); END """, [8]: """ @@ -94,7 +94,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "reminders" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'reminders'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); END """, [9]: """ @@ -102,7 +102,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "remindersListAssets" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListAssets'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); END """, [10]: """ @@ -110,7 +110,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "remindersListPrivates" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersListPrivates'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); END """, [11]: """ @@ -118,7 +118,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'remindersLists'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); END """, [12]: """ @@ -126,7 +126,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "tags" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."id" || ':' || 'tags'); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); END """, [13]: """ @@ -134,10 +134,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteRestricts', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [14]: """ @@ -145,10 +145,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [15]: """ @@ -156,10 +156,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [16]: """ @@ -167,10 +167,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "parents" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'parents', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [17]: """ @@ -178,10 +178,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminderTags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [18]: """ @@ -189,10 +189,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [19]: """ @@ -200,10 +200,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "remindersListAssets" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'remindersListAssets', "new"."id" || ':' || 'remindersListAssets', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [20]: """ @@ -211,10 +211,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [21]: """ @@ -222,10 +222,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersLists', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [22]: """ @@ -233,10 +233,10 @@ extension BaseCloudKitTests { AFTER INSERT ON "tags" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'tags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [23]: """ @@ -244,10 +244,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "childWithOnDeleteRestricts" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'childWithOnDeleteRestricts', "new"."id" || ':' || 'childWithOnDeleteRestricts', "new"."parentID" || ':' || 'parents' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteRestricts', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [24]: """ @@ -255,10 +255,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'childWithOnDeleteSetDefaults', "new"."id" || ':' || 'childWithOnDeleteSetDefaults', "new"."parentID" || ':' || 'parents' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [25]: """ @@ -266,10 +266,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'childWithOnDeleteSetNulls', "new"."id" || ':' || 'childWithOnDeleteSetNulls', "new"."parentID" || ':' || 'parents' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [26]: """ @@ -277,10 +277,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "parents" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'parents', "new"."id" || ':' || 'parents', NULL AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'parents', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [27]: """ @@ -288,10 +288,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'reminderTags', "new"."id" || ':' || 'reminderTags', NULL AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminderTags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [28]: """ @@ -299,10 +299,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'reminders', "new"."id" || ':' || 'reminders', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [29]: """ @@ -310,10 +310,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'remindersListAssets', "new"."id" || ':' || 'remindersListAssets', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [30]: """ @@ -321,10 +321,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'remindersListPrivates', "new"."id" || ':' || 'remindersListPrivates', "new"."remindersListID" || ':' || 'remindersLists' AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [31]: """ @@ -332,10 +332,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'remindersLists', "new"."id" || ':' || 'remindersLists', NULL AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersLists', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [32]: """ @@ -343,10 +343,10 @@ extension BaseCloudKitTests { AFTER UPDATE ON "tags" FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" - ("recordType", "recordName", "parentRecordName") - SELECT 'tags', "new"."id" || ':' || 'tags', NULL AS "foreignKey" - ON CONFLICT ("recordName") - DO UPDATE SET "recordName" = "excluded"."recordName", "parentRecordName" = "excluded"."parentRecordName", "userModificationDate" = "excluded"."userModificationDate"; + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'tags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, [33]: """ diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 5f30f80d..cd2d231f 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -8,7 +8,7 @@ import Testing extension PrimaryKeyedTable { static func recordID(for id: UUID, zoneID: CKRecordZone.ID? = nil) -> CKRecord.ID { CKRecord.ID( - recordName: self.recordName(for: id).rawValue, + recordName: self.recordName(for: id), zoneID: zoneID ?? SyncEngine.defaultZone.zoneID ) } From 72b22d1a473cdca73c148c64172dd4167f0a2357 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 19:02:44 -0700 Subject: [PATCH 345/581] wip --- .../CloudKit/CloudDatabase.swift | 1 + .../CloudKit/RecordType+MacroExpansion.swift | 12 +- .../SharingGRDBCore/CloudKit/RecordType.swift | 23 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 306 +++++++++++------- ...tUpTests.swift => SchemaChangeTests.swift} | 24 +- .../Internal/BaseCloudKitTests.swift | 2 +- 6 files changed, 229 insertions(+), 139 deletions(-) rename Tests/SharingGRDBTests/CloudKitTests/{SyncEngineSetUpTests.swift => SchemaChangeTests.swift} (82%) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift index d12cc1f3..cb89fbfa 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift @@ -3,6 +3,7 @@ import CloudKit package protocol CloudDatabase: AnyObject, Hashable, Sendable { var databaseScope: CKDatabase.Scope { get } + func record(for recordID: CKRecord.ID) async throws -> CKRecord @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift index 325d7190..eb02a725 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift @@ -5,7 +5,7 @@ extension RecordType { public typealias QueryValue = RecordType public let tableName = StructuredQueriesCore.TableColumn("tableName", keyPath: \QueryValue.tableName) public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) - public let tableInfo = StructuredQueriesCore.TableColumn("tableInfo", keyPath: \QueryValue.tableInfo) + public let tableInfo = StructuredQueriesCore.TableColumn.JSONRepresentation>("tableInfo", keyPath: \QueryValue.tableInfo) public var primaryKey: StructuredQueriesCore.TableColumn { self.tableName } @@ -18,12 +18,12 @@ extension RecordType { public typealias PrimaryTable = RecordType package let tableName: String? package let schema: String - package let tableInfo: [TableInfo] + package let tableInfo: Set public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Draft public let tableName = StructuredQueriesCore.TableColumn("tableName", keyPath: \QueryValue.tableName) public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) - public let tableInfo = StructuredQueriesCore.TableColumn("tableInfo", keyPath: \QueryValue.tableInfo) + public let tableInfo = StructuredQueriesCore.TableColumn.JSONRepresentation>("tableInfo", keyPath: \QueryValue.tableInfo) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.tableName, QueryValue.columns.schema, QueryValue.columns.tableInfo] } @@ -35,7 +35,7 @@ extension RecordType { public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { self.tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) - let tableInfo = try decoder.decode([TableInfo].JSONRepresentation.self) + let tableInfo = try decoder.decode(Set.JSONRepresentation.self) guard let schema else { throw QueryDecodingError.missingRequiredColumn } @@ -54,7 +54,7 @@ extension RecordType { public init( tableName: String? = nil, schema: String, - tableInfo: [TableInfo] + tableInfo: Set ) { self.tableName = tableName self.schema = schema @@ -69,7 +69,7 @@ extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.Primary public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) - let tableInfo = try decoder.decode([TableInfo].JSONRepresentation.self) + let tableInfo = try decoder.decode(Set.JSONRepresentation.self) guard let tableName else { throw QueryDecodingError.missingRequiredColumn } diff --git a/Sources/SharingGRDBCore/CloudKit/RecordType.swift b/Sources/SharingGRDBCore/CloudKit/RecordType.swift index 8e035429..4c1fee06 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordType.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordType.swift @@ -1,8 +1,27 @@ +import CustomDump + // @Table("\(String.sqliteDataCloudKitSchemaName)_recordTypes") package struct RecordType: Hashable { // @Column(primaryKey: true) package let tableName: String package let schema: String - // @Column(as: [TableInfo].JSONRepresentation.self) - package let tableInfo: [TableInfo] + // @Column(as: Set.JSONRepresentation.self) + package let tableInfo: Set +} + +extension RecordType: CustomDumpReflectable { + package var customDumpMirror: Mirror { + Mirror( + self, + children: [ + ("tableName", tableName as Any), + ("schema", schema as Any), + ( + "tableInfo", + tableInfo.sorted(by: { $0.name < $1.name }) as Any + ), + ], + displayStyle: .struct + ) + } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 24dcdd6b..4ab25dfe 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -10,11 +10,11 @@ zoneName: "co.pointfree.SQLiteData.defaultZone" ) - let userDatabase: UserDatabase - let logger: Logger + package let userDatabase: UserDatabase + package let logger: Logger package let metadatabase: any DatabaseReader - let tables: [any PrimaryKeyedTable.Type] - let privateTables: [any PrimaryKeyedTable.Type] + package let tables: [any PrimaryKeyedTable.Type] + package let privateTables: [any PrimaryKeyedTable.Type] let tablesByName: [String: any PrimaryKeyedTable.Type] private let tablesByOrder: [String: Int] let foreignKeysByTableName: [String: [ForeignKey]] @@ -208,34 +208,30 @@ return RecordType( tableName: schema.name, schema: sql, - tableInfo: try TableInfo.all(schema.name).fetchAll(db) + tableInfo: Set(try TableInfo.all(schema.name).fetchAll(db)) ) } } - let recordTypesToFetch = currentRecordTypes.compactMap { currentRecordType in - guard - let existingRecordType = previousRecordTypes.first(where: { previousRecordType in - currentRecordType.tableName == previousRecordType.tableName - }) - else { return (currentRecordType, isNewTable: true) } - return existingRecordType.schema == currentRecordType.schema - ? nil - : (currentRecordType, isNewTable: false) - } - - guard !recordTypesToFetch.isEmpty - else { return nil } - - try uploadRecordsToCloudKit( - recordTypes: recordTypesToFetch.compactMap { recordType, isNewTable in - isNewTable ? recordType : nil + cacheUserTables(recordTypes: currentRecordTypes) + let previousRecordTypeByTableName = Dictionary( + uniqueKeysWithValues: previousRecordTypes.map { + ($0.tableName, $0) + } + ) + let currentRecordTypeByTableName = Dictionary( + uniqueKeysWithValues: currentRecordTypes.map { + ($0.tableName, $0) } ) + try uploadRecordsToCloudKit( + previousRecordTypeByTableName: previousRecordTypeByTableName, + currentRecordTypeByTableName: currentRecordTypeByTableName + ) return Task { await withErrorReporting(.sqliteDataCloudKitFailure) { try await fetchChangesFromSchemaChange( - previousRecordTypes: previousRecordTypes, - currentRecordTypes: currentRecordTypes + previousRecordTypeByTableName: previousRecordTypeByTableName, + currentRecordTypeByTableName: currentRecordTypeByTableName ) } } @@ -251,20 +247,27 @@ } } - private func uploadRecordsToCloudKit(recordTypes: [RecordType]) throws { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in - try Self.$_isUpdatingRecord.withValue(false) { - for recordType in recordTypes { - guard let table = tablesByName[recordType.tableName] - else { continue } - - let parentForeignKey = - foreignKeysByTableName[recordType.tableName]?.count == 1 - ? foreignKeysByTableName[recordType.tableName]?.first - : nil + private func uploadRecordsToCloudKit( + previousRecordTypeByTableName: [String: RecordType], + currentRecordTypeByTableName: [String: RecordType] + ) throws { + let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in + previousRecordTypeByTableName[tableName] == nil + } - func open>(_: T.Type) throws { + try userDatabase.write { db in + try Self.$_isUpdatingRecord.withValue(false) { + for tableName in newTableNames { + guard let table = tablesByName[tableName] + else { continue } + + let parentForeignKey = + foreignKeysByTableName[tableName]?.count == 1 + ? foreignKeysByTableName[tableName]?.first + : nil + + func open>(_: T.Type) throws { + withErrorReporting(.sqliteDataCloudKitFailure) { try SyncMetadata.insert { columns in ( columns.recordType, @@ -296,48 +299,79 @@ } .execute(db) } - try open(table) } + try open(table) } } } } private func fetchChangesFromSchemaChange( - previousRecordTypes: [RecordType], - currentRecordTypes: [RecordType] + previousRecordTypeByTableName: [String: RecordType], + currentRecordTypeByTableName: [String: RecordType] ) async throws { - print("!!!") - for currentRecordType in currentRecordTypes { - guard let previousRecordType = previousRecordTypes.first( - where: { $0.tableName == currentRecordType.tableName } - ) else { - continue - } - if currentRecordType.tableInfo != previousRecordType.tableInfo { - print("!!!") + let tablesWithChangedSchemas = currentRecordTypeByTableName.filter { tableName, recordType in + previousRecordTypeByTableName[tableName]?.schema != recordType.schema + } + + for (tableName, recordType) in tablesWithChangedSchemas { + guard let table = tablesByName[tableName] + else { continue } + func open>(_: T.Type) async throws { + let previousRecordType = previousRecordTypeByTableName[tableName] + let changedColumns = + [T.columns.primaryKey.name] + + recordType.tableInfo.subtracting(previousRecordType?.tableInfo ?? []) + .map(\.name) + let lastKnownServerRecords = try await metadatabase.read { db in + try SyncMetadata + .where { $0.recordType.eq(tableName) } + .select(\._lastKnownServerRecordAllFields) + .fetchAll(db) + .compactMap(\.self) + } + for lastKnownServerRecord in lastKnownServerRecords { + let query = update(T.self, record: lastKnownServerRecord, columnNames: changedColumns) + try await userDatabase.write { db in + try SQLQueryExpression(query).execute(db) + } + } } + try await open(table) } -// // TODO: update data from local server records, do not fetch from CloudKit -// let lastKnownServerRecords = try await metadatabase.read { db in -// try SyncMetadata -// .where { -// $0.recordType.in(recordTypes.map(\.tableName)) -// && $0._lastKnownServerRecordAllFields.isNot(nil) -// } -// .select { -// SQLQueryExpression( -// "\($0._lastKnownServerRecordAllFields)", -// as: CKRecord.AllFieldsRepresentation.self -// ) -// } -// .fetchAll(db) -// } -// for record in lastKnownServerRecords { -// //upsertFromServerRecord(record) -// } + // print("!!!") + // for currentRecordType in currentRecordTypes { + // guard let previousRecordType = previousRecordTypes.first( + // where: { $0.tableName == currentRecordType.tableName } + // ) else { + // continue + // } + // + // if currentRecordType.tableInfo != previousRecordType.tableInfo { + // print("!!!") + // } + // } + + // // TODO: update data from local server records, do not fetch from CloudKit + // let lastKnownServerRecords = try await metadatabase.read { db in + // try SyncMetadata + // .where { + // $0.recordType.in(recordTypes.map(\.tableName)) + // && $0._lastKnownServerRecordAllFields.isNot(nil) + // } + // .select { + // SQLQueryExpression( + // "\($0._lastKnownServerRecordAllFields)", + // as: CKRecord.AllFieldsRepresentation.self + // ) + // } + // .fetchAll(db) + // } + // for record in lastKnownServerRecords { + // //upsertFromServerRecord(record) + // } } package func tearDownSyncEngine() async throws { @@ -371,24 +405,22 @@ _ = await (privateCancellation, sharedCancellation) } - #if DEBUG - public func deleteLocalData() async throws { - try await tearDownSyncEngine() - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in - for table in tables { - func open(_: T.Type) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try T.delete().execute(db) - } + func deleteLocalData() async throws { + try await tearDownSyncEngine() + withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.write { db in + for table in tables { + func open(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try T.delete().execute(db) } - open(table) } + open(table) } } - try await setUpSyncEngine() } - #endif + try await setUpSyncEngine() + } func didUpdate(recordName: SyncMetadata.RecordName, zoneID: CKRecordZone.ID?) { let zoneID = zoneID ?? Self.defaultZone.zoneID @@ -1010,38 +1042,8 @@ // TODO: Append more ON CONFLICT clauses for each unique constraint? // TODO: Use WHERE to scope the update? try userDatabase.write { db in - // TODO: Write a test for this: server sends record with nothing changed - if columnNames.contains(where: { $0 != T.columns.primaryKey.name }) { - var query: QueryFragment = "INSERT INTO \(T.self) (" - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) - query.append(") VALUES (") - let encryptedValues = serverRecord.encryptedValues - query.append( - columnNames - .map { columnName in - if let asset = serverRecord[columnName] as? CKAsset { - @Dependency(\.dataManager) var dataManager - return (try? asset.fileURL.map { try dataManager.load($0) })? - .queryFragment ?? "NULL" - } else { - return encryptedValues[columnName]?.queryFragment ?? "NULL" - } - } - .joined(separator: ", ") - ) - query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET ") - query.append( - columnNames - .filter { columnName in columnName != T.columns.primaryKey.name } - .map { - """ - \(quote: $0) = "excluded".\(quote: $0) - """ - } - .joined(separator: ",") - ) - try SQLQueryExpression(query).execute(db) - } + let query = upsert(T.self, record: serverRecord, columnNames: columnNames) + try SQLQueryExpression(query).execute(db) try SyncMetadata .find(recordName) .update { $0.setLastKnownServerRecord(serverRecord) } @@ -1416,4 +1418,84 @@ } } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func upsert>( + _: T.Type, + record: CKRecord, + columnNames: some Collection + ) -> QueryFragment { + guard + columnNames.contains(where: { $0 != T.columns.primaryKey.name }) + else { + return "" + } + var query: QueryFragment = "INSERT INTO \(T.self) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + query.append( + columnNames + .map { columnName in + if let asset = record[columnName] as? CKAsset { + @Dependency(\.dataManager) var dataManager + return (try? asset.fileURL.map { try dataManager.load($0) })? + .queryFragment ?? "NULL" + } else { + return record.encryptedValues[columnName]?.queryFragment ?? "NULL" + } + } + .joined(separator: ", ") + ) + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET ") + query.append( + columnNames + .filter { columnName in columnName != T.columns.primaryKey.name } + .map { + """ + \(quote: $0) = "excluded".\(quote: $0) + """ + } + .joined(separator: ",") + ) + return query + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func update>( + _: T.Type, + record: CKRecord, + columnNames: some Collection + ) -> QueryFragment { + let nonPrimaryKeyColumns = columnNames.filter { $0 != T.columns.primaryKey.name } + guard + !nonPrimaryKeyColumns.isEmpty, + let id = record.encryptedValues[T.columns.primaryKey.name] as? String + else { + return "" + } + var query: QueryFragment = "UPDATE \(T.self) SET " + query.append( + columnNames + .filter { columnName in columnName != T.columns.primaryKey.name } + .map { columnName in + if let asset = record[columnName] as? CKAsset { + @Dependency(\.dataManager) var dataManager + let data = try? asset.fileURL.map { try dataManager.load($0) } + if data == nil { + reportIssue("Asset data not found on disk") + } + return """ + \(quote: columnName) = \(data?.queryFragment ?? "NULL") + """ + } else { + return """ + \(quote: columnName) = \(record.encryptedValues[columnName]?.queryFragment ?? "NULL") + """ + } + } + .joined(separator: ",") + ) + query.append("WHERE \(T.columns.primaryKey) = \(bind: id)") + return query + } #endif diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift similarity index 82% rename from Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift rename to Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index 11610bb8..cea9b15a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineSetUpTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -8,11 +8,11 @@ import Testing extension BaseCloudKitTests { @MainActor - final class SetUpTests: BaseCloudKitTests, @unchecked Sendable { + final class SchemaChangeTests: BaseCloudKitTests, @unchecked Sendable { @Dependency(\.date.now) var now @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func schemaChange() async throws { + @Test func addColumnToRemindersAndRemindersLists() async throws { let personalList = RemindersList(id: UUID(1), title: "Personal") let businessList = RemindersList(id: UUID(2), title: "Business") let reminder = Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) @@ -68,22 +68,10 @@ extension BaseCloudKitTests { let relaunchedSyncEngine = try await SyncEngine( container: syncEngine.container, - userDatabase: self.userDatabase, - metadatabaseURL: URL - .metadatabase(containerIdentifier: syncEngine.container.containerIdentifier!), - tables: [ - MigratedReminder.self, - MigratedRemindersList.self, - Tag.self, - ReminderTag.self, - Parent.self, - ChildWithOnDeleteRestrict.self, - ChildWithOnDeleteSetNull.self, - ChildWithOnDeleteSetDefault.self, - ], - privateTables: [ - RemindersListPrivate.self - ] + userDatabase: syncEngine.userDatabase, + metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), + tables: syncEngine.tables, + privateTables: syncEngine.privateTables ) await relaunchedSyncEngine.processBatch() diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index af8ed408..141e57cc 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -7,7 +7,7 @@ import SnapshotTesting import Testing @Suite( - .snapshots(record: .failed), + .snapshots(record: .missing), .dependencies { $0.date.now = Date(timeIntervalSince1970: 0) $0.dataManager = InMemoryDataManager() From ffb2ed333c55528b0c13e7ebe97fa56fb546d9ad Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 19:07:44 -0700 Subject: [PATCH 346/581] update snaps --- .../CloudKitTests/CloudKitTests.swift | 327 ++++++++++++++- .../CloudKitTests/RecordTypeTests.swift | 380 +++++++++++++++++- 2 files changed, 676 insertions(+), 31 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index f0352203..382b72e7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -27,7 +27,23 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] ), [1]: RecordType( tableName: "remindersListAssets", @@ -37,7 +53,30 @@ extension BaseCloudKitTests { "coverImage" BLOB NOT NULL, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "coverImage", + notNull: true, + type: "BLOB" + ), + [1]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "TEXT" + ) + ] ), [2]: RecordType( tableName: "remindersListPrivates", @@ -47,7 +86,30 @@ extension BaseCloudKitTests { "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "position", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "TEXT" + ) + ] ), [3]: RecordType( tableName: "reminders", @@ -62,7 +124,51 @@ extension BaseCloudKitTests { FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "dueDate", + notNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isCompleted", + notNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "priority", + notNull: false, + type: "INTEGER" + ), + [4]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "TEXT" + ), + [5]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] ), [4]: RecordType( tableName: "tags", @@ -71,7 +177,23 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] ), [5]: RecordType( tableName: "reminderTags", @@ -81,7 +203,30 @@ extension BaseCloudKitTests { "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "reminderID", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "tagID", + notNull: true, + type: "TEXT" + ) + ] ), [6]: RecordType( tableName: "parents", @@ -89,7 +234,16 @@ extension BaseCloudKitTests { CREATE TABLE "parents"( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ) + ] ), [7]: RecordType( tableName: "childWithOnDeleteRestricts", @@ -98,7 +252,23 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: true, + type: "TEXT" + ) + ] ), [8]: RecordType( tableName: "childWithOnDeleteSetNulls", @@ -107,7 +277,23 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "TEXT" + ) + ] ), [9]: RecordType( tableName: "childWithOnDeleteSetDefaults", @@ -116,18 +302,83 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "\'00000000-0000-0000-0000-000000000000\'", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "TEXT" + ) + ] ), [10]: RecordType( + tableName: "localUsers", + schema: """ + CREATE TABLE "localUsers" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "name", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "TEXT" + ) + ] + ), + [11]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "count" INTEGER NOT NULL ) - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "count", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ) + ] ), - [11]: RecordType( + [12]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( @@ -135,9 +386,32 @@ extension BaseCloudKitTests { "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "isOn", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelAID", + notNull: true, + type: "INTEGER" + ) + ] ), - [12]: RecordType( + [13]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( @@ -145,7 +419,30 @@ extension BaseCloudKitTests { "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelBID", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] ) ] """# diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 13b86942..32bb3f95 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -24,7 +24,23 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] ), [1]: RecordType( tableName: "remindersListAssets", @@ -34,7 +50,30 @@ extension BaseCloudKitTests { "coverImage" BLOB NOT NULL, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "coverImage", + notNull: true, + type: "BLOB" + ), + [1]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "TEXT" + ) + ] ), [2]: RecordType( tableName: "remindersListPrivates", @@ -44,7 +83,30 @@ extension BaseCloudKitTests { "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "position", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "TEXT" + ) + ] ), [3]: RecordType( tableName: "reminders", @@ -59,7 +121,51 @@ extension BaseCloudKitTests { FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "dueDate", + notNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isCompleted", + notNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "priority", + notNull: false, + type: "INTEGER" + ), + [4]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "TEXT" + ), + [5]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] ), [4]: RecordType( tableName: "tags", @@ -68,7 +174,23 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] ), [5]: RecordType( tableName: "reminderTags", @@ -78,7 +200,30 @@ extension BaseCloudKitTests { "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "reminderID", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "tagID", + notNull: true, + type: "TEXT" + ) + ] ), [6]: RecordType( tableName: "parents", @@ -86,7 +231,16 @@ extension BaseCloudKitTests { CREATE TABLE "parents"( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ) + ] ), [7]: RecordType( tableName: "childWithOnDeleteRestricts", @@ -95,7 +249,23 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: true, + type: "TEXT" + ) + ] ), [8]: RecordType( tableName: "childWithOnDeleteSetNulls", @@ -104,7 +274,23 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "TEXT" + ) + ] ), [9]: RecordType( tableName: "childWithOnDeleteSetDefaults", @@ -113,18 +299,83 @@ extension BaseCloudKitTests { "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "\'00000000-0000-0000-0000-000000000000\'", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "TEXT" + ) + ] ), [10]: RecordType( + tableName: "localUsers", + schema: """ + CREATE TABLE "localUsers" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "name", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "TEXT" + ) + ] + ), + [11]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "count" INTEGER NOT NULL ) - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "count", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ) + ] ), - [11]: RecordType( + [12]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( @@ -132,9 +383,32 @@ extension BaseCloudKitTests { "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "isOn", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelAID", + notNull: true, + type: "INTEGER" + ) + ] ), - [12]: RecordType( + [13]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( @@ -142,7 +416,30 @@ extension BaseCloudKitTests { "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelBID", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] ) ] """# @@ -207,7 +504,58 @@ extension BaseCloudKitTests { FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT - """ + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "dueDate", + notNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: "uuid()", + isPrimaryKey: true, + name: "id", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isCompleted", + notNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "newFeature", + notNull: true, + type: "INTEGER" + ), + [4]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "priority", + notNull: false, + type: "INTEGER" + ), + [5]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "TEXT" + ), + [6]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] ) """# } From 186202d679ebc2c3829fa119380a727cc5247eea Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 19:28:35 -0700 Subject: [PATCH 347/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 17 --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 37 +---- .../CloudKitTests/SchemaChangeTests.swift | 143 +++++++++++++++++- 3 files changed, 137 insertions(+), 60 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 08433563..aa3ec4b5 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -175,23 +175,6 @@ extension CKRecord { return true } - @discardableResult - package func setValue( - _ newValue: CKAsset, - data: @autoclosure () -> [UInt8], - forKey key: CKRecord.FieldKey, - at userModificationDate: Date - ) -> Bool { - guard - encryptedValues[at: key] < userModificationDate, - (self[key] as? CKAsset)?.fileURL != newValue.fileURL - else { return false } - self[key] = newValue - encryptedValues[at: key] = userModificationDate - self.userModificationDate = userModificationDate - return true - } - @discardableResult package func removeValue( forKey key: CKRecord.FieldKey, diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4ab25dfe..39080afe 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -184,10 +184,6 @@ } } - /* - - */ - let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) syncEngines.withValue { $0 = SyncEngines( @@ -340,38 +336,6 @@ } try await open(table) } - - // print("!!!") - // for currentRecordType in currentRecordTypes { - // guard let previousRecordType = previousRecordTypes.first( - // where: { $0.tableName == currentRecordType.tableName } - // ) else { - // continue - // } - // - // if currentRecordType.tableInfo != previousRecordType.tableInfo { - // print("!!!") - // } - // } - - // // TODO: update data from local server records, do not fetch from CloudKit - // let lastKnownServerRecords = try await metadatabase.read { db in - // try SyncMetadata - // .where { - // $0.recordType.in(recordTypes.map(\.tableName)) - // && $0._lastKnownServerRecordAllFields.isNot(nil) - // } - // .select { - // SQLQueryExpression( - // "\($0._lastKnownServerRecordAllFields)", - // as: CKRecord.AllFieldsRepresentation.self - // ) - // } - // .fetchAll(db) - // } - // for record in lastKnownServerRecords { - // //upsertFromServerRecord(record) - // } } package func tearDownSyncEngine() async throws { @@ -1482,6 +1446,7 @@ @Dependency(\.dataManager) var dataManager let data = try? asset.fileURL.map { try dataManager.load($0) } if data == nil { + // TODO: Handle assets that need to be re-downloaded reportIssue("Asset data not found on disk") } return """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index cea9b15a..8ada70a4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -10,6 +10,10 @@ extension BaseCloudKitTests { @MainActor final class SchemaChangeTests: BaseCloudKitTests, @unchecked Sendable { @Dependency(\.date.now) var now + @Dependency(\.dataManager) var dataManager + var inMemoryDataManager: InMemoryDataManager { + dataManager as! InMemoryDataManager + } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addColumnToRemindersAndRemindersLists() async throws { @@ -77,41 +81,166 @@ extension BaseCloudKitTests { await relaunchedSyncEngine.processBatch() let remindersLists = try await userDatabase.userRead { db in - try MigratedRemindersList.order(by: \.id).fetchAll(db) + try RemindersListWithPosition.order(by: \.id).fetchAll(db) } let reminders = try await userDatabase.userRead { db in - try MigratedReminder.order(by: \.id).fetchAll(db) + try ReminderWithPosition.order(by: \.id).fetchAll(db) } expectNoDifference( remindersLists, [ - MigratedRemindersList(id: UUID(1), title: "Personal", position: 1), - MigratedRemindersList(id: UUID(2), title: "Business", position: 2), + RemindersListWithPosition(id: UUID(1), title: "Personal", position: 1), + RemindersListWithPosition(id: UUID(2), title: "Business", position: 2), ] ) expectNoDifference( reminders, [ - MigratedReminder(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), + ReminderWithPosition(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), ] ) } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addAssetToRemindersList() async throws { + let personalList = RemindersList(id: UUID(1), title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + personalList + } + } + + await syncEngine.processBatch() + + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + let personalListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: UUID(1)) + ) + personalListRecord.setValue(Array("image".utf8), forKey: "image", at: now) + + await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord] + ) + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), + tables: syncEngine.tables, + privateTables: syncEngine.privateTables + ) + + await relaunchedSyncEngine.processBatch() + + let remindersLists = try await userDatabase.userRead { db in + try RemindersListWithData.order(by: \.id).fetchAll(db) + } + + expectNoDifference( + remindersLists, + [ + RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal"), + ] + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addAssetToRemindersList_RemovedFromStorage() async throws { + let personalList = RemindersList(id: UUID(1), title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + personalList + } + } + + await syncEngine.processBatch() + + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + let personalListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: UUID(1)) + ) + personalListRecord.setValue(Array("image".utf8), forKey: "image", at: now) + + await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord] + ) + + inMemoryDataManager.storage.withValue { $0.removeAll() } + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), + tables: syncEngine.tables, + privateTables: syncEngine.privateTables + ) + + await relaunchedSyncEngine.processBatch() + + let remindersLists = try await userDatabase.userRead { db in + try RemindersListWithData.order(by: \.id).fetchAll(db) + } + + withKnownIssue("TODO: Handle assets that need to be re-downloaded") { + expectNoDifference( + remindersLists, + [ + RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal"), + ] + ) + } + } + } } } @Table("remindersLists") -fileprivate struct MigratedRemindersList: Equatable, Identifiable { +fileprivate struct RemindersListWithPosition: Equatable, Identifiable { let id: UUID var title = "" var position = 0 } @Table("reminders") -fileprivate struct MigratedReminder: Equatable, Identifiable { +fileprivate struct ReminderWithPosition: Equatable, Identifiable { let id: UUID var title = "" var position = 0 var remindersListID: RemindersList.ID } + +@Table("remindersLists") +fileprivate struct RemindersListWithData: Equatable, Identifiable { + let id: UUID + var image: Data + var title = "" +} From 518b89b25623aa391ee11fa7128fa7c5069d20a8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 19:30:53 -0700 Subject: [PATCH 348/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../CloudKitTests/SchemaChangeTests.swift | 73 ++++++++++--------- 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 39080afe..a89ae5e6 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1460,7 +1460,7 @@ } .joined(separator: ",") ) - query.append("WHERE \(T.columns.primaryKey) = \(bind: id)") + query.append(" WHERE \(T.columns.primaryKey) = \(bind: id)") return query } #endif diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index 8ada70a4..225d3f21 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -55,17 +55,17 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 - """ + """ + ALTER TABLE "remindersLists" + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + """ ) .execute(db) try #sql( - """ - ALTER TABLE "reminders" - ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 - """ + """ + ALTER TABLE "reminders" + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + """ ) .execute(db) } @@ -97,7 +97,12 @@ extension BaseCloudKitTests { expectNoDifference( reminders, [ - ReminderWithPosition(id: UUID(1), title: "Get milk", position: 3, remindersListID: UUID(1)), + ReminderWithPosition( + id: UUID(1), + title: "Get milk", + position: 3, + remindersListID: UUID(1) + ) ] ) } @@ -129,10 +134,10 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' - """ + """ + ALTER TABLE "remindersLists" + ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' + """ ) .execute(db) } @@ -154,7 +159,7 @@ extension BaseCloudKitTests { expectNoDifference( remindersLists, [ - RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal"), + RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal") ] ) } @@ -188,33 +193,33 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' - """ + """ + ALTER TABLE "remindersLists" + ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' + """ ) .execute(db) } - let relaunchedSyncEngine = try await SyncEngine( - container: syncEngine.container, - userDatabase: syncEngine.userDatabase, - metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), - tables: syncEngine.tables, - privateTables: syncEngine.privateTables - ) + await withKnownIssue("TODO: Handle assets that need to be re-downloaded") { + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), + tables: syncEngine.tables, + privateTables: syncEngine.privateTables + ) - await relaunchedSyncEngine.processBatch() + await relaunchedSyncEngine.processBatch() - let remindersLists = try await userDatabase.userRead { db in - try RemindersListWithData.order(by: \.id).fetchAll(db) - } + let remindersLists = try await userDatabase.userRead { db in + try RemindersListWithData.order(by: \.id).fetchAll(db) + } - withKnownIssue("TODO: Handle assets that need to be re-downloaded") { expectNoDifference( remindersLists, [ - RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal"), + RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal") ] ) } @@ -224,14 +229,14 @@ extension BaseCloudKitTests { } @Table("remindersLists") -fileprivate struct RemindersListWithPosition: Equatable, Identifiable { +private struct RemindersListWithPosition: Equatable, Identifiable { let id: UUID var title = "" var position = 0 } @Table("reminders") -fileprivate struct ReminderWithPosition: Equatable, Identifiable { +private struct ReminderWithPosition: Equatable, Identifiable { let id: UUID var title = "" var position = 0 @@ -239,7 +244,7 @@ fileprivate struct ReminderWithPosition: Equatable, Identifiable { } @Table("remindersLists") -fileprivate struct RemindersListWithData: Equatable, Identifiable { +private struct RemindersListWithData: Equatable, Identifiable { let id: UUID var image: Data var title = "" From 1089159482f99c944dd56384ce9c11fa1296b73c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 10 Jul 2025 19:50:26 -0700 Subject: [PATCH 349/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4f671a6a..2071200d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1087,7 +1087,6 @@ } } - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension CKRecord.ID { var recordType: String? { guard From 91669285aea550c2b3a373e04f2060914d1993bd Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 11 Jul 2025 12:13:26 -0700 Subject: [PATCH 350/581] wip --- Package.resolved | 2 +- .../CloudKit/Metadatabase.swift | 5 +- .../CloudKit/RecordTypeTable.swift | 53 +++++++------ .../StateSerialization+MacroExpansion.swift | 40 +++++----- .../SyncMetadata+MacroExpansion.swift | 78 +++++++++++++------ .../CloudKit/SyncMetadata.swift | 15 ++-- .../SharingGRDBCore/CloudKit/Triggers.swift | 8 +- .../CloudKitTests/NewTableSyncTests.swift | 2 + .../NextRecordZoneChangeBatchTests.swift | 4 - 9 files changed, 123 insertions(+), 84 deletions(-) diff --git a/Package.resolved b/Package.resolved index e4b94aa4..83f46bee 100644 --- a/Package.resolved +++ b/Package.resolved @@ -124,7 +124,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "main", - "revision" : "a6bc6d56fc967c3956fce8c25bd6f051ac6934af" + "revision" : "fb29d5a6d35f5e57e1479d1da6ad51d8d6f23f66" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index caa1b815..b76ea6e4 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -45,6 +45,7 @@ func defaultMetadatabase( "lastKnownServerRecord" BLOB, "_lastKnownServerRecordAllFields" BLOB, "share" BLOB, + "isShared" INTEGER NOT NULL AS ("share" IS NOT NULL), "userModificationDate" TEXT NOT NULL DEFAULT (\(.datetime())), PRIMARY KEY ("recordPrimaryKey", "recordType"), @@ -57,8 +58,8 @@ func defaultMetadatabase( // TODO: Do we ever query for "parentRecordName"? should we add an index? try SQLQueryExpression( """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_share" - ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("share") WHERE "share" IS NOT NULL + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("isShared") """ ) .execute(db) diff --git a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift index 06f34e66..36d0f9c6 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift @@ -6,46 +6,47 @@ package struct RecordType: Hashable { } // NB: This is generated by inlining the above macro applications. -extension RecordType: StructuredQueriesCore.Table, PrimaryKeyedTable { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore - .PrimaryKeyedTableDefinition - { +extension RecordType { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { public typealias QueryValue = RecordType - public let tableName = StructuredQueriesCore.TableColumn( - "tableName", - keyPath: \QueryValue.tableName - ) - public let schema = StructuredQueriesCore.TableColumn( - "schema", - keyPath: \QueryValue.schema - ) + public let tableName = StructuredQueriesCore.TableColumn("tableName", keyPath: \QueryValue.tableName) + public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) public var primaryKey: StructuredQueriesCore.TableColumn { self.tableName } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.tableName, QueryValue.columns.schema] } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [QueryValue.columns.tableName, QueryValue.columns.schema] + } + public var queryFragment: QueryFragment { + "\(self.tableName), \(self.schema)" + } } + public struct Draft: StructuredQueriesCore.TableDraft { public typealias PrimaryTable = RecordType - let tableName: String? - let schema: String + package let tableName: String? + package let schema: String public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = RecordType.Draft - public let tableName = StructuredQueriesCore.TableColumn( - "tableName", - keyPath: \QueryValue.tableName - ) - public let schema = StructuredQueriesCore.TableColumn( - "schema", - keyPath: \QueryValue.schema - ) + public typealias QueryValue = Draft + public let tableName = StructuredQueriesCore.TableColumn("tableName", keyPath: \QueryValue.tableName) + public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.tableName, QueryValue.columns.schema] } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [QueryValue.columns.tableName, QueryValue.columns.schema] + } + public var queryFragment: QueryFragment { + "\(self.tableName), \(self.schema)" + } } public static let columns = TableColumns() + public static let tableName = RecordType.tableName + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { self.tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) @@ -54,6 +55,7 @@ extension RecordType: StructuredQueriesCore.Table, PrimaryKeyedTable { } self.schema = schema } + public init(_ other: RecordType) { self.tableName = other.tableName self.schema = other.schema @@ -66,8 +68,11 @@ extension RecordType: StructuredQueriesCore.Table, PrimaryKeyedTable { self.schema = schema } } +} + +extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public static let columns = TableColumns() - public static let tableName = "\(String.sqliteDataCloudKitSchemaName)_recordTypes" + public static let tableName = "sqlitedata_icloud_recordTypes" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift index 73767e2b..05c1d19e 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift @@ -4,24 +4,22 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension StateSerialization { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore - .PrimaryKeyedTableDefinition - { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { public typealias QueryValue = StateSerialization - public let scope = StructuredQueriesCore.TableColumn< - QueryValue, CKDatabase.Scope.RawValueRepresentation - >("scope", keyPath: \QueryValue.scope) - public let data = StructuredQueriesCore.TableColumn< - QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation - >("data", keyPath: \QueryValue.data) - public var primaryKey: - StructuredQueriesCore.TableColumn - { + public let scope = StructuredQueriesCore.TableColumn("scope", keyPath: \QueryValue.scope) + public let data = StructuredQueriesCore.TableColumn("data", keyPath: \QueryValue.data) + public var primaryKey: StructuredQueriesCore.TableColumn { self.scope } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.scope, QueryValue.columns.data] } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [QueryValue.columns.scope, QueryValue.columns.data] + } + public var queryFragment: QueryFragment { + "\(self.scope), \(self.data)" + } } public struct Draft: StructuredQueriesCore.TableDraft { @@ -30,15 +28,17 @@ package var data: CKSyncEngine.State.Serialization public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Draft - public let scope = StructuredQueriesCore.TableColumn< - QueryValue, CKDatabase.Scope.RawValueRepresentation? - >("scope", keyPath: \QueryValue.scope) - public let data = StructuredQueriesCore.TableColumn< - QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation - >("data", keyPath: \QueryValue.data) + public let scope = StructuredQueriesCore.TableColumn("scope", keyPath: \QueryValue.scope) + public let data = StructuredQueriesCore.TableColumn("data", keyPath: \QueryValue.data) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.scope, QueryValue.columns.data] } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [QueryValue.columns.scope, QueryValue.columns.data] + } + public var queryFragment: QueryFragment { + "\(self.scope), \(self.data)" + } } public static let columns = TableColumns() @@ -67,9 +67,7 @@ } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable - { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { public static let columns = TableColumns() public static let tableName = "sqlitedata_icloud_stateSerialization" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index ea3b6ff8..1d0b2896 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -13,8 +13,8 @@ "recordType", keyPath: \QueryValue.recordType ) - public var recordName: some StructuredQueriesCore.QueryExpression { - StructuredQueriesCore.TableColumn( + public var recordName: StructuredQueriesCore.GeneratedColumn { + StructuredQueriesCore.GeneratedColumn( "recordName", keyPath: \QueryValue.recordName ) @@ -27,8 +27,8 @@ "parentRecordType", keyPath: \QueryValue.parentRecordType ) - public var parentRecordName: some StructuredQueriesCore.QueryExpression { - StructuredQueriesCore.TableColumn( + public var parentRecordName: StructuredQueriesCore.GeneratedColumn { + StructuredQueriesCore.GeneratedColumn( "parentRecordName", keyPath: \QueryValue.parentRecordName ) @@ -39,11 +39,26 @@ public let share = StructuredQueriesCore.TableColumn< QueryValue, CKShare?.SystemFieldsRepresentation >("share", keyPath: \QueryValue.share) + public var isShared: StructuredQueriesCore.GeneratedColumn { + StructuredQueriesCore.GeneratedColumn( + "isShared", + keyPath: \QueryValue.isShared + ) + } public let userModificationDate = StructuredQueriesCore.TableColumn( "userModificationDate", keyPath: \QueryValue.userModificationDate ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [ + QueryValue.columns.recordPrimaryKey, QueryValue.columns.recordType, + QueryValue.columns.recordName, QueryValue.columns.parentRecordPrimaryKey, + QueryValue.columns.parentRecordType, QueryValue.columns.parentRecordName, + QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, + QueryValue.columns.isShared, QueryValue.columns.userModificationDate, + ] + } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { [ QueryValue.columns.recordPrimaryKey, QueryValue.columns.recordType, QueryValue.columns.parentRecordPrimaryKey, QueryValue.columns.parentRecordType, @@ -52,7 +67,7 @@ ] } public var queryFragment: QueryFragment { - "\(self.recordPrimaryKey), \(self.recordType), \(self.recordName), \(self.parentRecordPrimaryKey), \(self.parentRecordType), \(self.parentRecordName), \(self.lastKnownServerRecord), \(self.share), \(self.userModificationDate)" + "\(self.recordPrimaryKey), \(self.recordType), \(self.recordName), \(self.parentRecordPrimaryKey), \(self.parentRecordType), \(self.parentRecordName), \(self.lastKnownServerRecord), \(self.share), \(self.isShared), \(self.userModificationDate)" } } } @@ -70,6 +85,7 @@ self.parentRecordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) + let isShared = try decoder.decode(Bool.self) let userModificationDate = try decoder.decode(Date.self) guard let recordPrimaryKey else { throw QueryDecodingError.missingRequiredColumn @@ -86,6 +102,9 @@ guard let share else { throw QueryDecodingError.missingRequiredColumn } + guard let isShared else { + throw QueryDecodingError.missingRequiredColumn + } guard let userModificationDate else { throw QueryDecodingError.missingRequiredColumn } @@ -94,19 +113,22 @@ self.recordName = recordName self.lastKnownServerRecord = lastKnownServerRecord self.share = share + self.isShared = isShared self.userModificationDate = userModificationDate } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncMetadata.AncestorMetadata: StructuredQueriesCore.Table { + extension SyncMetadata.AncestorMetadata { public struct Columns: StructuredQueriesCore.QueryExpression { public typealias QueryValue = SyncMetadata.AncestorMetadata public let queryFragment: StructuredQueriesCore.QueryFragment public init( recordName: some StructuredQueriesCore.QueryExpression, parentRecordName: some StructuredQueriesCore.QueryExpression, - lastKnownServerRecord: some StructuredQueriesCore.QueryExpression + lastKnownServerRecord: some StructuredQueriesCore.QueryExpression< + CKRecord?.SystemFieldsRepresentation + > ) { self.queryFragment = """ \(recordName.queryFragment) AS "recordName", \(parentRecordName.queryFragment) AS "parentRecordName", \(lastKnownServerRecord.queryFragment) AS "lastKnownServerRecord" @@ -116,38 +138,50 @@ public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = SyncMetadata.AncestorMetadata - public let recordName = StructuredQueriesCore.TableColumn< - QueryValue, String - >("recordName", keyPath: \QueryValue.recordName) - public let parentRecordName = StructuredQueriesCore.TableColumn< - QueryValue, String? - >("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn( - "lastKnownServerRecord", - keyPath: \QueryValue.lastKnownServerRecord + public let recordName = StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + public let parentRecordName = StructuredQueriesCore.TableColumn( + "parentRecordName", + keyPath: \QueryValue.parentRecordName ) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.SystemFieldsRepresentation + >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordName, QueryValue.columns.parentRecordName] + [ + QueryValue.columns.recordName, QueryValue.columns.parentRecordName, + QueryValue.columns.lastKnownServerRecord, + ] + } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [ + QueryValue.columns.recordName, QueryValue.columns.parentRecordName, + QueryValue.columns.lastKnownServerRecord, + ] + } + public var queryFragment: QueryFragment { + "\(self.recordName), \(self.parentRecordName), \(self.lastKnownServerRecord)" } } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata.AncestorMetadata: StructuredQueriesCore.Table { public static let columns = TableColumns() public static let tableName = "ancestorMetadatas" public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordName = try decoder.decode(String.self) - let parentRecordName = try decoder.decode(String?.self) + self.parentRecordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) guard let recordName else { throw QueryDecodingError.missingRequiredColumn } - guard let parentRecordName else { - throw QueryDecodingError.missingRequiredColumn - } guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } self.recordName = recordName - self.parentRecordName = parentRecordName self.lastKnownServerRecord = lastKnownServerRecord } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index e4c192b3..08acb432 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -43,6 +43,7 @@ public struct SyncMetadata: Hashable, Sendable { /// ```swift /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" /// ``` + // @Column(generated: .virtual) public let parentRecordName: String? /// The last known `CKRecord` received from the server. @@ -53,9 +54,8 @@ public struct SyncMetadata: Hashable, Sendable { // @Column(as: CKShare?.SystemFieldsRepresentation.self) public var share: CKShare? - // TODO: Add generated column and index it instead of current 'WHERE IS NOT NULL' index // @Column(generated: .virtual) - // public let isShared: Bool + public let isShared: Bool /// The date the user last modified the record. public var userModificationDate: Date @@ -63,22 +63,25 @@ public struct SyncMetadata: Hashable, Sendable { package init( recordPrimaryKey: String, recordType: String, - recordName: String, parentRecordPrimaryKey: String? = nil, parentRecordType: String? = nil, - parentRecordName: String?, lastKnownServerRecord: CKRecord? = nil, share: CKShare? = nil, userModificationDate: Date ) { self.recordPrimaryKey = recordPrimaryKey self.recordType = recordType - self.recordName = recordName + self.recordName = "\(recordPrimaryKey):\(recordType)" self.parentRecordPrimaryKey = parentRecordPrimaryKey self.parentRecordType = parentRecordType - self.parentRecordName = parentRecordName + if let parentRecordPrimaryKey, let parentRecordType { + self.parentRecordName = "\(parentRecordPrimaryKey):\(parentRecordType)" + } else { + self.parentRecordName = nil + } self.lastKnownServerRecord = lastKnownServerRecord self.share = share + self.isShared = share != nil self.userModificationDate = userModificationDate } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 9b39a0ae..32203835 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -122,9 +122,9 @@ extension QueryExpression where Self == SQLQueryExpression<()> { _ new: StructuredQueriesCore.TableAlias.Operation._New>.TableColumns ) -> Self { .didUpdate( - recordName: SQLQueryExpression(#""new"."recordName""#), + recordName: new.recordName, lastKnownServerRecord: new.lastKnownServerRecord - ?? rootServerRecord(recordName: SQLQueryExpression(#""new"."recordName""#)) + ?? rootServerRecord(recordName: new.recordName) ) } @@ -135,9 +135,9 @@ extension QueryExpression where Self == SQLQueryExpression<()> { -> Self { .didDelete( - recordName: SQLQueryExpression(#""old"."recordName""#), + recordName: SQLQueryExpression(old.recordName), lastKnownServerRecord: old.lastKnownServerRecord - ?? rootServerRecord(recordName: SQLQueryExpression(#""old"."recordName""#)) + ?? rootServerRecord(recordName: SQLQueryExpression(old.recordName)) ) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 37938816..28c1b06c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -79,6 +79,7 @@ extension BaseCloudKitTests { share: nil ), share: nil, + isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) ), [1]: SyncMetadata( @@ -95,6 +96,7 @@ extension BaseCloudKitTests { share: nil ), share: nil, + isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) ) ] diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index ac8f134a..dffbcd40 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -37,8 +37,6 @@ extension BaseCloudKitTests { SyncMetadata( recordPrimaryKey: UUID(1).uuidString.lowercased(), recordType: UnrecognizedTable.tableName, - recordName: UnrecognizedTable.recordName(for: UUID(1)), - parentRecordName: nil, userModificationDate: .distantPast ) } @@ -68,8 +66,6 @@ extension BaseCloudKitTests { SyncMetadata( recordPrimaryKey: UUID(1).uuidString.lowercased(), recordType: RemindersList.tableName, - recordName: RemindersList.recordName(for: UUID(1)), - parentRecordName: nil, userModificationDate: .distantPast ) } From 35de25a7f280eb5a69303d806c640ac1c8d62eae Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 11 Jul 2025 12:14:48 -0700 Subject: [PATCH 351/581] format --- .../CloudKit/RecordTypeTable.swift | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift index 36d0f9c6..2a49cf31 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift @@ -7,10 +7,18 @@ package struct RecordType: Hashable { // NB: This is generated by inlining the above macro applications. extension RecordType { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { + public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore + .PrimaryKeyedTableDefinition + { public typealias QueryValue = RecordType - public let tableName = StructuredQueriesCore.TableColumn("tableName", keyPath: \QueryValue.tableName) - public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) + public let tableName = StructuredQueriesCore.TableColumn( + "tableName", + keyPath: \QueryValue.tableName + ) + public let schema = StructuredQueriesCore.TableColumn( + "schema", + keyPath: \QueryValue.schema + ) public var primaryKey: StructuredQueriesCore.TableColumn { self.tableName } @@ -31,8 +39,14 @@ extension RecordType { package let schema: String public struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Draft - public let tableName = StructuredQueriesCore.TableColumn("tableName", keyPath: \QueryValue.tableName) - public let schema = StructuredQueriesCore.TableColumn("schema", keyPath: \QueryValue.schema) + public let tableName = StructuredQueriesCore.TableColumn( + "tableName", + keyPath: \QueryValue.tableName + ) + public let schema = StructuredQueriesCore.TableColumn( + "schema", + keyPath: \QueryValue.schema + ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.tableName, QueryValue.columns.schema] } From 9d3c57f07b9931ed3ba2dfc6c597f33baedec16c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 11 Jul 2025 13:11:30 -0700 Subject: [PATCH 352/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 116 ++++++++++-------- .../CloudKitTests/SchemaChangeTests.swift | 48 ++++---- .../Internal/CloudKitTestHelpers.swift | 27 ++++ 3 files changed, 116 insertions(+), 75 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index a89ae5e6..b42aa5fc 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -208,7 +208,7 @@ ) } } - cacheUserTables(recordTypes: currentRecordTypes) + defer { cacheUserTables(recordTypes: currentRecordTypes) } let previousRecordTypeByTableName = Dictionary( uniqueKeysWithValues: previousRecordTypes.map { ($0.tableName, $0) @@ -306,20 +306,19 @@ previousRecordTypeByTableName: [String: RecordType], currentRecordTypeByTableName: [String: RecordType] ) async throws { - let tablesWithChangedSchemas = currentRecordTypeByTableName.filter { tableName, recordType in previousRecordTypeByTableName[tableName]?.schema != recordType.schema } - for (tableName, recordType) in tablesWithChangedSchemas { + for (tableName, currentRecordType) in tablesWithChangedSchemas { guard let table = tablesByName[tableName] else { continue } func open>(_: T.Type) async throws { let previousRecordType = previousRecordTypeByTableName[tableName] - let changedColumns = - [T.columns.primaryKey.name] - + recordType.tableInfo.subtracting(previousRecordType?.tableInfo ?? []) - .map(\.name) + let changedColumns = currentRecordType.tableInfo.subtracting( + previousRecordType?.tableInfo ?? [] + ) + .map(\.name) let lastKnownServerRecords = try await metadatabase.read { db in try SyncMetadata .where { $0.recordType.eq(tableName) } @@ -328,7 +327,11 @@ .compactMap(\.self) } for lastKnownServerRecord in lastKnownServerRecords { - let query = update(T.self, record: lastKnownServerRecord, columnNames: changedColumns) + let query = try await updateQuery( + T.self, + record: lastKnownServerRecord, + columnNames: changedColumns + ) try await userDatabase.write { db in try SQLQueryExpression(query).execute(db) } @@ -783,6 +786,7 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { + // TODO: If a CKShare comes in before a CKRecord with a share, then the cacheShare will not write anything let shares: [CKShare] = [] for record in modifications { if let share = record as? CKShare { @@ -957,11 +961,11 @@ } private func upsertFromServerRecord(_ serverRecord: CKRecord) { + // TODO: Upfront cache this server record regardless if table is recognized withErrorReporting(.sqliteDataCloudKitFailure) { guard let table = tablesByName[serverRecord.recordType] else { - // TODO: Should we be reporting this? - // What if another device makes changes to a table this device doesn't know about? + // TODO: Do not report this reportIssue( .sqliteDataCloudKitFailure.appending( """ @@ -1053,6 +1057,58 @@ } ?? nil } + + private func updateQuery>( + _: T.Type, + record: CKRecord, + columnNames: some Collection + ) async throws -> QueryFragment { + @Dependency(\.dataManager) var dataManager + + let nonPrimaryKeyColumns = columnNames.filter { $0 != T.columns.primaryKey.name } + guard + !nonPrimaryKeyColumns.isEmpty, + let id = record.encryptedValues[T.columns.primaryKey.name] as? String + else { + return "" + } + var query: QueryFragment = "UPDATE \(T.self) SET " + + + var record = record + let recordHasAsset = nonPrimaryKeyColumns.contains { columnName in + record[columnName] is CKAsset + } + if recordHasAsset { + record = try await container.database(for: record.recordID).record(for: record.recordID) + } + + var columnAssignments: [QueryFragment] = [] + for columnName in nonPrimaryKeyColumns { + if let asset = record[columnName] as? CKAsset { + let data = try? asset.fileURL.map { try dataManager.load($0) } + if data == nil { + // TODO: Handle assets that need to be re-downloaded + reportIssue("Asset data not found on disk") + } + columnAssignments.append( + """ + \(quote: columnName) = \(data?.queryFragment ?? "NULL") + """ + ) + } else { + columnAssignments.append( + """ + \(quote: columnName) = \(record.encryptedValues[columnName]?.queryFragment ?? "NULL") + """ + ) + } + } + + query.append(columnAssignments.joined(separator: ",")) + query.append(" WHERE \(T.columns.primaryKey) = \(bind: id)") + return query + } } @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) @@ -1423,44 +1479,4 @@ ) return query } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - private func update>( - _: T.Type, - record: CKRecord, - columnNames: some Collection - ) -> QueryFragment { - let nonPrimaryKeyColumns = columnNames.filter { $0 != T.columns.primaryKey.name } - guard - !nonPrimaryKeyColumns.isEmpty, - let id = record.encryptedValues[T.columns.primaryKey.name] as? String - else { - return "" - } - var query: QueryFragment = "UPDATE \(T.self) SET " - query.append( - columnNames - .filter { columnName in columnName != T.columns.primaryKey.name } - .map { columnName in - if let asset = record[columnName] as? CKAsset { - @Dependency(\.dataManager) var dataManager - let data = try? asset.fileURL.map { try dataManager.load($0) } - if data == nil { - // TODO: Handle assets that need to be re-downloaded - reportIssue("Asset data not found on disk") - } - return """ - \(quote: columnName) = \(data?.queryFragment ?? "NULL") - """ - } else { - return """ - \(quote: columnName) = \(record.encryptedValues[columnName]?.queryFragment ?? "NULL") - """ - } - } - .joined(separator: ",") - ) - query.append(" WHERE \(T.columns.primaryKey) = \(bind: id)") - return query - } #endif diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index 225d3f21..431c7c90 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -183,14 +183,14 @@ extension BaseCloudKitTests { for: RemindersList.recordID(for: UUID(1)) ) personalListRecord.setValue(Array("image".utf8), forKey: "image", at: now) - + await syncEngine.modifyRecords( scope: .private, saving: [personalListRecord] ) - + inMemoryDataManager.storage.withValue { $0.removeAll() } - + try await userDatabase.userWrite { db in try #sql( """ @@ -200,29 +200,27 @@ extension BaseCloudKitTests { ) .execute(db) } - - await withKnownIssue("TODO: Handle assets that need to be re-downloaded") { - let relaunchedSyncEngine = try await SyncEngine( - container: syncEngine.container, - userDatabase: syncEngine.userDatabase, - metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), - tables: syncEngine.tables, - privateTables: syncEngine.privateTables - ) - - await relaunchedSyncEngine.processBatch() - - let remindersLists = try await userDatabase.userRead { db in - try RemindersListWithData.order(by: \.id).fetchAll(db) - } - - expectNoDifference( - remindersLists, - [ - RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal") - ] - ) + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), + tables: syncEngine.tables, + privateTables: syncEngine.privateTables + ) + + await relaunchedSyncEngine.processBatch() + + let remindersLists = try await userDatabase.userRead { db in + try RemindersListWithData.order(by: \.id).fetchAll(db) } + + expectNoDifference( + remindersLists, + [ + RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal") + ] + ) } } } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index bcf8b15e..f5ca9cad 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -236,8 +236,16 @@ final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectabl final class MockCloudDatabase: CloudDatabase { let storage = LockIsolated<[CKRecord.ID: CKRecord]>([:]) + let assets = LockIsolated<[AssetID: Data]>([:]) let databaseScope: CKDatabase.Scope + let dataManager = Dependency(\.dataManager) + + struct AssetID: Hashable { + let recordID: CKRecord.ID + let key: String + } + struct RecordNotFound: Error {} init(databaseScope: CKDatabase.Scope) { @@ -249,6 +257,17 @@ final class MockCloudDatabase: CloudDatabase { else { throw RecordNotFound() } guard let record = record.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } + + try assets.withValue { assets in + for key in record.allKeys() { + guard let assetData = assets[AssetID(recordID: record.recordID, key: key)] + else { continue } + let url = URL(filePath: UUID().uuidString.lowercased()) + try dataManager.wrappedValue.save(assetData, to: url) + record[key] = CKAsset(fileURL: url) + } + } + return record } @@ -295,6 +314,14 @@ final class MockCloudDatabase: CloudDatabase { guard let copy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } copy._recordChangeTag = UUID().uuidString + assets.withValue { assets in + for key in copy.allKeys() { + guard let assetURL = (copy[key] as? CKAsset)?.fileURL + else { continue } + assets[AssetID(recordID: copy.recordID, key: key)] = try? dataManager.wrappedValue + .load(assetURL) + } + } storage[recordToSave.recordID] = copy saveResults[recordToSave.recordID] = .success(copy) } From 19738729ff76ba877e829a7d8355f35a9fb950ba Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 11 Jul 2025 14:40:22 -0700 Subject: [PATCH 353/581] wip --- Sources/SharingGRDBCore/CloudKit/ForeignKey.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/Metadatabase.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift | 2 ++ Sources/SharingGRDBCore/CloudKit/Triggers.swift | 2 ++ 4 files changed, 8 insertions(+) diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index 86909f50..9b16bfd2 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import Foundation import StructuredQueriesCore @@ -297,3 +298,4 @@ struct ForeignKey: QueryDecodable, QueryRepresentable { } } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index b76ea6e4..611af0fb 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import Foundation import os @@ -91,3 +92,4 @@ extension QueryFragment { Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift index 2a49cf31..1bf7ff28 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordTypeTable.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) // @Table("\(String.sqliteDataCloudKitSchemaName)_recordTypes") package struct RecordType: Hashable { // @Column(primaryKey: true) @@ -100,3 +101,4 @@ extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.Primary self.schema = schema } } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 32203835..042c401c 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -1,3 +1,4 @@ +#if canImport(CloudKit) import CloudKit import Foundation @@ -196,3 +197,4 @@ private func rootServerRecord( .where { $0.parentRecordName.is(nil) } } } +#endif From 1868df3660b6b453a143a11d7c46ba58a6a5086a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 11 Jul 2025 14:42:33 -0700 Subject: [PATCH 354/581] wip --- .../SharingGRDBCore/CloudKit/Triggers.swift | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 042c401c..39f510c4 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -172,23 +172,11 @@ private func rootServerRecord( With { SyncMetadata .where { $0.recordName.eq(recordName) } - .select { - SyncMetadata.AncestorMetadata.Columns( - recordName: $0.recordName, - parentRecordName: $0.parentRecordName, - lastKnownServerRecord: $0.lastKnownServerRecord - ) - } + .select { SyncMetadata.AncestorMetadata.Columns($0) } .union( all: true, SyncMetadata - .select { - SyncMetadata.AncestorMetadata.Columns( - recordName: $0.recordName, - parentRecordName: $0.parentRecordName, - lastKnownServerRecord: $0.lastKnownServerRecord - ) - } + .select { SyncMetadata.AncestorMetadata.Columns($0) } .join(SyncMetadata.AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) } ) } query: { @@ -197,4 +185,15 @@ private func rootServerRecord( .where { $0.parentRecordName.is(nil) } } } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata.AncestorMetadata.Columns { + fileprivate init(_ metadata: SyncMetadata.TableColumns) { + self.init( + recordName: metadata.recordName, + parentRecordName: metadata.parentRecordName, + lastKnownServerRecord: metadata.lastKnownServerRecord + ) + } +} #endif From 80e73d476b9676fde67a2b1405d6b4baa0310bbf Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 11 Jul 2025 14:51:02 -0700 Subject: [PATCH 355/581] wip --- .../SharingGRDBCore/CloudKit/Triggers.swift | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 39f510c4..3321b41c 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -109,18 +109,24 @@ extension SyncMetadata { "after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, after: .delete { old in - Values(.didDelete(old)) + Values(.didDelete( + recordName: old.recordName, + lastKnownServerRecord: old.lastKnownServerRecord + ?? rootServerRecord(recordName: old.recordName) + )) } when: { _ in !SyncEngine.isUpdatingRecord() } ) } -// TODO: can we remove a layer of didUpdate/didDelete? extension QueryExpression where Self == SQLQueryExpression<()> { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) fileprivate static func didUpdate( - _ new: StructuredQueriesCore.TableAlias.Operation._New>.TableColumns + _ new: StructuredQueriesCore.TableAlias< + SyncMetadata, TemporaryTrigger.Operation._New + > + .TableColumns ) -> Self { .didUpdate( recordName: new.recordName, @@ -129,19 +135,6 @@ extension QueryExpression where Self == SQLQueryExpression<()> { ) } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func didDelete( - _ old: StructuredQueriesCore.TableAlias.Operation._Old>.TableColumns - ) - -> Self - { - .didDelete( - recordName: SQLQueryExpression(old.recordName), - lastKnownServerRecord: old.lastKnownServerRecord - ?? rootServerRecord(recordName: SQLQueryExpression(old.recordName)) - ) - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private static func didUpdate( recordName: some QueryExpression, @@ -151,12 +144,10 @@ extension QueryExpression where Self == SQLQueryExpression<()> { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - private static func didDelete( + fileprivate static func didDelete( recordName: some QueryExpression, lastKnownServerRecord: some QueryExpression - ) - -> Self - { + ) -> Self { Self("\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord))") } } From 15f7bad34a71eda998dc222afbce9867ddec8618 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 11 Jul 2025 14:51:35 -0700 Subject: [PATCH 356/581] wip --- Sources/SharingGRDBCore/CloudKit/Triggers.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 3321b41c..9b3c8fb0 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -16,7 +16,7 @@ extension PrimaryKeyedTable { createTemporaryTrigger( "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", ifNotExists: true, - after: .insert { new in SyncMetadata.insert(new: new, parentForeignKey: parentForeignKey) } + after: .insert { new in SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } ) } @@ -24,7 +24,7 @@ extension PrimaryKeyedTable { createTemporaryTrigger( "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", ifNotExists: true, - after: .update { _, new in SyncMetadata.insert(new: new, parentForeignKey: parentForeignKey) } + after: .update { _, new in SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } ) } @@ -46,7 +46,7 @@ extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - fileprivate static func insert>( + fileprivate static func upsert>( new: TemporaryTrigger.Operation.New, parentForeignKey: ForeignKey?, ) -> some StructuredQueriesCore.Statement { From e59c132ef3e39457c331d4bd14852470f2f8d898 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 11 Jul 2025 14:54:51 -0700 Subject: [PATCH 357/581] wip --- Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift | 12 ++++++------ Sources/SharingGRDBCore/CloudKit/Metadatabase.swift | 9 +++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift index d12cc1f3..e13cf8f8 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift @@ -5,13 +5,13 @@ package protocol CloudDatabase: AnyObject, Hashable, Sendable { var databaseScope: CKDatabase.Scope { get } func record(for recordID: CKRecord.ID) async throws -> CKRecord - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) func records( for ids: [CKRecord.ID], desiredKeys: [CKRecord.FieldKey]? ) async throws -> [CKRecord.ID : Result] - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) func modifyRecords( saving recordsToSave: [CKRecord], deleting recordIDsToDelete: [CKRecord.ID], @@ -24,7 +24,7 @@ package protocol CloudDatabase: AnyObject, Hashable, Sendable { } extension CloudDatabase { - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) func modifyRecords( saving recordsToSave: [CKRecord], deleting recordIDsToDelete: [CKRecord.ID] @@ -40,7 +40,7 @@ extension CloudDatabase { ) } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) package func records( for ids: [CKRecord.ID] ) async throws -> [CKRecord.ID : Result] { @@ -64,7 +64,7 @@ final class AnyCloudDatabase: CloudDatabase { try await rawValue.record(for: recordID) } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) func records( for ids: [CKRecord.ID], desiredKeys: [CKRecord.FieldKey]? @@ -72,7 +72,7 @@ final class AnyCloudDatabase: CloudDatabase { try await rawValue.records(for: ids) } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) func modifyRecords( saving recordsToSave: [CKRecord], deleting recordIDsToDelete: [CKRecord.ID], diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 611af0fb..c9e611a0 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -55,8 +55,13 @@ func defaultMetadatabase( """ ) .execute(db) - // TODO: Should we add an index to recordType? - // TODO: Do we ever query for "parentRecordName"? should we add an index? + try SQLQueryExpression( + """ + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("parentRecordName") + """ + ) + .execute(db) try SQLQueryExpression( """ CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" From 270ee307a199455a8858f03f710b2cd4bc07d90d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 11 Jul 2025 16:15:37 -0700 Subject: [PATCH 358/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 105 +++++++++--------- .../CloudKitTests/SchemaChangeTests.swift | 24 ++-- 2 files changed, 70 insertions(+), 59 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6d539f1b..713e3f1f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -24,6 +24,8 @@ -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) package let container: any CloudContainer + let dataManager = Dependency(\.dataManager) + public convenience init( container: CKContainer, database: any DatabaseWriter, @@ -259,15 +261,15 @@ else { continue } let parentForeignKey = - foreignKeysByTableName[tableName]?.count == 1 - ? foreignKeysByTableName[tableName]?.first - : nil + foreignKeysByTableName[tableName]?.count == 1 + ? foreignKeysByTableName[tableName]?.first + : nil func open>(_: T.Type) throws { let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = - parentForeignKey + parentForeignKey .map { ("\(T.self).\(quote: $0.from)", "\(bind: $0.table)") } - ?? ("NULL", "NULL") + ?? ("NULL", "NULL") try SyncMetadata.insert { columns in ( columns.recordPrimaryKey, @@ -313,15 +315,17 @@ let lastKnownServerRecords = try await metadatabase.read { db in try SyncMetadata .where { $0.recordType.eq(tableName) } + // TODO: make this a regular column .select(\._lastKnownServerRecordAllFields) .fetchAll(db) .compactMap(\.self) } for lastKnownServerRecord in lastKnownServerRecords { let query = try await updateQuery( - T.self, + for: T.self, record: lastKnownServerRecord, - columnNames: changedColumns + columnNames: T.TableColumns.allColumns.map(\.name), + changedColumnNames: changedColumns ) try await userDatabase.write { db in try SQLQueryExpression(query).execute(db) @@ -952,18 +956,12 @@ } private func upsertFromServerRecord(_ serverRecord: CKRecord) { - // TODO: Upfront cache this server record regardless if table is recognized withErrorReporting(.sqliteDataCloudKitFailure) { guard let table = tablesByName[serverRecord.recordType] else { - // TODO: Do not report this - reportIssue( - .sqliteDataCloudKitFailure.appending( - """ - : No table to merge from: "\(serverRecord.recordType)" - """ - ) - ) + // TODO: Upfront cache this server record regardless if table is recognized + // TODO: test + return } @@ -1048,54 +1046,61 @@ } private func updateQuery>( - _: T.Type, + for _: T.Type, record: CKRecord, - columnNames: some Collection + columnNames: some Collection, + changedColumnNames: some Collection ) async throws -> QueryFragment { - @Dependency(\.dataManager) var dataManager - - let nonPrimaryKeyColumns = columnNames.filter { $0 != T.columns.primaryKey.name } + let nonPrimaryKeyChangedColumns = + changedColumnNames + .filter { $0 != T.columns.primaryKey.name } guard - !nonPrimaryKeyColumns.isEmpty, - let id = record.encryptedValues[T.columns.primaryKey.name] as? String + !nonPrimaryKeyChangedColumns.isEmpty else { return "" } - var query: QueryFragment = "UPDATE \(T.self) SET " - - var record = record - let recordHasAsset = nonPrimaryKeyColumns.contains { columnName in + let recordHasAsset = nonPrimaryKeyChangedColumns.contains { columnName in record[columnName] is CKAsset } if recordHasAsset { record = try await container.database(for: record.recordID).record(for: record.recordID) } - var columnAssignments: [QueryFragment] = [] - for columnName in nonPrimaryKeyColumns { - if let asset = record[columnName] as? CKAsset { - let data = try? asset.fileURL.map { try dataManager.load($0) } - if data == nil { - // TODO: Handle assets that need to be re-downloaded - reportIssue("Asset data not found on disk") + var query: QueryFragment = "INSERT INTO \(T.self) (" + query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(") VALUES (") + query.append( + columnNames + .map { columnName in + if let asset = record[columnName] as? CKAsset { + let data = try? asset.fileURL.map { try dataManager.wrappedValue.load($0) } + if data == nil { + reportIssue("Asset data not found on disk") + } + return data?.queryFragment ?? "NULL" + } else { + return record.encryptedValues[columnName]?.queryFragment ?? "NULL" + } } - columnAssignments.append( - """ - \(quote: columnName) = \(data?.queryFragment ?? "NULL") - """ - ) - } else { - columnAssignments.append( - """ - \(quote: columnName) = \(record.encryptedValues[columnName]?.queryFragment ?? "NULL") - """ - ) - } - } - - query.append(columnAssignments.joined(separator: ",")) - query.append(" WHERE \(T.columns.primaryKey) = \(bind: id)") + .joined(separator: ", ") + ) + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET ") + query.append( + nonPrimaryKeyChangedColumns + .map { columnName in + if let asset = record[columnName] as? CKAsset { + let data = try? asset.fileURL.map { try dataManager.wrappedValue.load($0) } + if data == nil { + reportIssue("Asset data not found on disk") + } + return "\(quote: columnName) = \(data?.queryFragment ?? "NULL")" + } else { + return "\(quote: columnName) = \(record.encryptedValues[columnName]?.queryFragment ?? "NULL")" + } + } + .joined(separator: ",") + ) return query } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index 431c7c90..cdc21591 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -74,7 +74,9 @@ extension BaseCloudKitTests { container: syncEngine.container, userDatabase: syncEngine.userDatabase, metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), - tables: syncEngine.tables, + tables: syncEngine.tables + .filter { $0 != Reminder.self && $0 != RemindersList.self } + + [ReminderWithPosition.self, RemindersListWithPosition.self], privateTables: syncEngine.privateTables ) @@ -146,7 +148,9 @@ extension BaseCloudKitTests { container: syncEngine.container, userDatabase: syncEngine.userDatabase, metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), - tables: syncEngine.tables, + tables: syncEngine.tables + .filter { $0 != RemindersList.self } + + [RemindersListWithData.self], privateTables: syncEngine.privateTables ) @@ -183,14 +187,14 @@ extension BaseCloudKitTests { for: RemindersList.recordID(for: UUID(1)) ) personalListRecord.setValue(Array("image".utf8), forKey: "image", at: now) - + await syncEngine.modifyRecords( scope: .private, saving: [personalListRecord] ) - + inMemoryDataManager.storage.withValue { $0.removeAll() } - + try await userDatabase.userWrite { db in try #sql( """ @@ -200,7 +204,7 @@ extension BaseCloudKitTests { ) .execute(db) } - + let relaunchedSyncEngine = try await SyncEngine( container: syncEngine.container, userDatabase: syncEngine.userDatabase, @@ -208,13 +212,13 @@ extension BaseCloudKitTests { tables: syncEngine.tables, privateTables: syncEngine.privateTables ) - + await relaunchedSyncEngine.processBatch() - + let remindersLists = try await userDatabase.userRead { db in try RemindersListWithData.order(by: \.id).fetchAll(db) } - + expectNoDifference( remindersLists, [ @@ -223,6 +227,8 @@ extension BaseCloudKitTests { ) } } + + // TODO: tests with multiple assets } } From 1ad9d01769839c1dce199a46c89e1875f749dc7f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 11 Jul 2025 16:58:22 -0700 Subject: [PATCH 359/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 37 +++++++- .../SyncMetadata+MacroExpansion.swift | 20 +++- .../CloudKit/SyncMetadata.swift | 30 ++---- .../CloudKitTests/NewTableSyncTests.swift | 18 ++++ .../CloudKitTests/SchemaChangeTests.swift | 92 +++++++++++++++++-- 5 files changed, 160 insertions(+), 37 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 713e3f1f..46b34709 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -315,7 +315,6 @@ let lastKnownServerRecords = try await metadatabase.read { db in try SyncMetadata .where { $0.recordType.eq(tableName) } - // TODO: make this a regular column .select(\._lastKnownServerRecordAllFields) .fetchAll(db) .compactMap(\.self) @@ -959,9 +958,27 @@ withErrorReporting(.sqliteDataCloudKitFailure) { guard let table = tablesByName[serverRecord.recordType] else { - // TODO: Upfront cache this server record regardless if table is recognized - // TODO: test - + guard let recordPrimaryKey = serverRecord.recordID.recordPrimaryKey + else { return } + try userDatabase.write { db in + try SyncMetadata.insert { + SyncMetadata( + recordPrimaryKey: recordPrimaryKey, + recordType: serverRecord.recordType, + parentRecordPrimaryKey: serverRecord.parent?.recordID.recordPrimaryKey, + parentRecordType: serverRecord.parent?.recordID.recordType, + lastKnownServerRecord: serverRecord, + _lastKnownServerRecordAllFields: serverRecord, + share: nil, + userModificationDate: serverRecord.userModificationDate + ) + } onConflict: { + ($0.recordPrimaryKey, $0.recordType) + } doUpdate: { + $0.setLastKnownServerRecord(serverRecord) + } + .execute(db) + } return } @@ -1129,6 +1146,18 @@ guard !recordTypeBytes.isEmpty else { return nil } return String(Substring(recordTypeBytes)) } + + var recordPrimaryKey: String? { + guard + let i = recordName.utf8.lastIndex(of: .init(ascii: ":")), + let j = recordName.utf8.index(i, offsetBy: 1, limitedBy: recordName.utf8.endIndex) + else { return nil } + let recordPrimaryKeyBytes = recordName.utf8[..("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + package let _lastKnownServerRecordAllFields = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.AllFieldsRepresentation + >("_lastKnownServerRecordAllFields", keyPath: \QueryValue._lastKnownServerRecordAllFields) public let share = StructuredQueriesCore.TableColumn< QueryValue, CKShare?.SystemFieldsRepresentation >("share", keyPath: \QueryValue.share) @@ -54,7 +57,9 @@ QueryValue.columns.recordPrimaryKey, QueryValue.columns.recordType, QueryValue.columns.recordName, QueryValue.columns.parentRecordPrimaryKey, QueryValue.columns.parentRecordType, QueryValue.columns.parentRecordName, - QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, + QueryValue.columns.lastKnownServerRecord, + QueryValue.columns._lastKnownServerRecordAllFields, + QueryValue.columns.share, QueryValue.columns.isShared, QueryValue.columns.userModificationDate, ] } @@ -62,12 +67,14 @@ [ QueryValue.columns.recordPrimaryKey, QueryValue.columns.recordType, QueryValue.columns.parentRecordPrimaryKey, QueryValue.columns.parentRecordType, - QueryValue.columns.lastKnownServerRecord, QueryValue.columns.share, + QueryValue.columns.lastKnownServerRecord, + QueryValue.columns._lastKnownServerRecordAllFields, + QueryValue.columns.share, QueryValue.columns.userModificationDate, ] } public var queryFragment: QueryFragment { - "\(self.recordPrimaryKey), \(self.recordType), \(self.recordName), \(self.parentRecordPrimaryKey), \(self.parentRecordType), \(self.parentRecordName), \(self.lastKnownServerRecord), \(self.share), \(self.isShared), \(self.userModificationDate)" + "\(self.recordPrimaryKey), \(self.recordType), \(self.recordName), \(self.parentRecordPrimaryKey), \(self.parentRecordType), \(self.parentRecordName), \(self.lastKnownServerRecord), \(self._lastKnownServerRecordAllFields), \(self.share), \(self.isShared), \(self.userModificationDate)" } } } @@ -84,6 +91,9 @@ self.parentRecordType = try decoder.decode(String.self) self.parentRecordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) + let _lastKnownServerRecordAllFields = try decoder.decode( + CKRecord?.AllFieldsRepresentation.self + ) let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) let isShared = try decoder.decode(Bool.self) let userModificationDate = try decoder.decode(Date.self) @@ -99,6 +109,9 @@ guard let lastKnownServerRecord else { throw QueryDecodingError.missingRequiredColumn } + guard let _lastKnownServerRecordAllFields else { + throw QueryDecodingError.missingRequiredColumn + } guard let share else { throw QueryDecodingError.missingRequiredColumn } @@ -112,6 +125,7 @@ self.recordType = recordType self.recordName = recordName self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields self.share = share self.isShared = isShared self.userModificationDate = userModificationDate diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 08acb432..1d323ce1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -47,9 +47,15 @@ public struct SyncMetadata: Hashable, Sendable { public let parentRecordName: String? /// The last known `CKRecord` received from the server. + /// + /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`. // @Column(as: CKRecord?.SystemFieldsRepresentation.self) public var lastKnownServerRecord: CKRecord? + /// The last known `CKRecord` received from the server with all fields archived. + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + package var _lastKnownServerRecordAllFields: CKRecord? + /// The `CKShare` associated with this record, if it is shared. // @Column(as: CKShare?.SystemFieldsRepresentation.self) public var share: CKShare? @@ -66,6 +72,7 @@ public struct SyncMetadata: Hashable, Sendable { parentRecordPrimaryKey: String? = nil, parentRecordType: String? = nil, lastKnownServerRecord: CKRecord? = nil, + _lastKnownServerRecordAllFields: CKRecord? = nil, share: CKShare? = nil, userModificationDate: Date ) { @@ -80,6 +87,7 @@ public struct SyncMetadata: Hashable, Sendable { self.parentRecordName = nil } self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields self.share = share self.isShared = share != nil self.userModificationDate = userModificationDate @@ -94,30 +102,8 @@ public struct SyncMetadata: Hashable, Sendable { } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata.TableColumns { - package var _lastKnownServerRecordAllFields: StructuredQueriesCore.TableColumn< - SyncMetadata, - CKRecord?.AllFieldsRepresentation - > { - StructuredQueriesCore.TableColumn( - "_lastKnownServerRecordAllFields", - keyPath: \._lastKnownServerRecordAllFields - ) - } -} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - fileprivate var _lastKnownServerRecordAllFields: CKRecord? { - fatalError( - """ - Never invoke this directly. Use 'SyncMetadata.TableColumns._lastKnownServerRecordAllFields' \ - instead. - """ - ) - } - package static func find( _ primaryKey: T.PrimaryKey.QueryOutput, table _: T.Type, diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 28c1b06c..a26dfe90 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -78,6 +78,16 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil ), + _lastKnownServerRecordAllFields: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Write blog post" + ), share: nil, isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) @@ -95,6 +105,14 @@ extension BaseCloudKitTests { parent: nil, share: nil ), + _lastKnownServerRecordAllFields: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: "00000000-0000-0000-0000-000000000001", + title: "Personal" + ), share: nil, isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index cdc21591..4b85f5c3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -170,11 +170,12 @@ extension BaseCloudKitTests { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addAssetToRemindersList_RemovedFromStorage() async throws { - let personalList = RemindersList(id: UUID(1), title: "Personal") + @Test func addAssetToRemindersList_Redownload() async throws { try await userDatabase.userWrite { db in try db.seed { - personalList + RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: UUID(2), title: "Business") + RemindersList(id: UUID(3), title: "Secret") } } @@ -186,11 +187,19 @@ extension BaseCloudKitTests { let personalListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: UUID(1)) ) - personalListRecord.setValue(Array("image".utf8), forKey: "image", at: now) + personalListRecord.setValue(Array("personal-image".utf8), forKey: "image", at: now) + let businessListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: UUID(2)) + ) + businessListRecord.setValue(Array("business-image".utf8), forKey: "image", at: now) + let secretListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: UUID(3)) + ) + secretListRecord.setValue(Array("secret-image".utf8), forKey: "image", at: now) await syncEngine.modifyRecords( scope: .private, - saving: [personalListRecord] + saving: [personalListRecord, businessListRecord, secretListRecord] ) inMemoryDataManager.storage.withValue { $0.removeAll() } @@ -209,7 +218,9 @@ extension BaseCloudKitTests { container: syncEngine.container, userDatabase: syncEngine.userDatabase, metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), - tables: syncEngine.tables, + tables: syncEngine.tables + .filter { $0 != RemindersList.self } + + [RemindersListWithData.self], privateTables: syncEngine.privateTables ) @@ -222,13 +233,71 @@ extension BaseCloudKitTests { expectNoDifference( remindersLists, [ - RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal") + RemindersListWithData(id: UUID(1), image: Data("personal-image".utf8), title: "Personal"), + RemindersListWithData(id: UUID(2), image: Data("business-image".utf8), title: "Business"), + RemindersListWithData(id: UUID(3), image: Data("secret-image".utf8), title: "Secret"), ] ) } } - // TODO: tests with multiple assets + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func newTable() async throws { + await syncEngine.processBatch() + + try await withDependencies { + $0.date.now.addTimeInterval(60) + } operation: { + let imageRecord = CKRecord( + recordType: "images", + recordID: Image.recordID(for: UUID(1)) + ) + imageRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + imageRecord.setValue("A good image", forKey: "caption", at: now) + imageRecord.setValue(Data("image".utf8), forKey: "image", at: now) + + await syncEngine.modifyRecords( + scope: .private, + saving: [imageRecord] + ) + + inMemoryDataManager.storage.withValue { $0.removeAll() } + + try await userDatabase.userWrite { db in + try #sql( + """ + CREATE TABLE "images" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "caption" TEXT NOT NULL, + "image" BLOB NOT NULL + ) + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), + tables: syncEngine.tables + [Image.self], + privateTables: syncEngine.privateTables + ) + + await relaunchedSyncEngine.processBatch() + + let images = try await userDatabase.userRead { db in + try Image.order(by: \.id).fetchAll(db) + } + + expectNoDifference( + images, + [ + Image(id: UUID(1), image: Data("image".utf8), caption: "A good image") + ] + ) + } + } } } @@ -253,3 +322,10 @@ private struct RemindersListWithData: Equatable, Identifiable { var image: Data var title = "" } + +@Table +private struct Image: Equatable, Identifiable { + let id: UUID + var image: Data + var caption = "" +} From 166ce30b89dc2741e6142755e2b591ba83b34631 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 11 Jul 2025 19:46:20 -0700 Subject: [PATCH 360/581] Fixed a couple of todos. --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 66 ++++++--- .../CloudKitTests/SharingTests.swift | 131 ++++++++++++++++++ 3 files changed, 176 insertions(+), 23 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0560f673..15cf694d 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -142,7 +142,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "main", - "revision" : "a6bc6d56fc967c3956fce8c25bd6f051ac6934af" + "revision" : "2d9f1d94f5cbfbd37c5ab0b2500b385db95516a3" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 46b34709..d5890ca1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -418,6 +418,7 @@ ) } + // TODO: Possible to get test coverage on this? package func acceptShare(metadata: ShareMetadata) async throws { guard let metadata = metadata.rawValue else { @@ -430,8 +431,13 @@ return } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) - // TODO: do something with the CKShare returned? save it in SyncMetadata? - _ = try await container.accept(metadata) + let share = try await container.accept(metadata) + try await userDatabase.write { db in + try SyncMetadata + .where { $0.recordName.eq(rootRecordID.recordName) } + .update { $0.share = share } + .execute(db) + } try await syncEngines.shared?.fetchChanges( .init( scope: .zoneIDs([rootRecordID.zoneID]), @@ -786,23 +792,39 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { - // TODO: If a CKShare comes in before a CKRecord with a share, then the cacheShare will not write anything - let shares: [CKShare] = [] + enum ShareOrReference { + case share(CKShare) + case reference(CKShare.Reference) + } + var shares: [ShareOrReference] = [] for record in modifications { if let share = record as? CKShare { - await withErrorReporting { - try await cacheShare(share) - } + shares.append(.share(share)) } else { upsertFromServerRecord(record) + if let shareReference = record.share { + shares.append(.reference(shareReference)) + } } - if let shareReference = record.share, - // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in - let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), - let share = shareRecord as? CKShare - { - await withErrorReporting { - try await cacheShare(share) + } + + await withTaskGroup(of: Void.self) { group in + for share in shares { + group.addTask { + switch share { + case .share(let share): + await withErrorReporting { + try await self.cacheShare(share) + } + case .reference(let shareReference): + guard + let record = try? await syncEngine.database.record(for: shareReference.recordID), + let share = record as? CKShare + else { return } + await withErrorReporting { + try await self.cacheShare(share) + } + } } } } @@ -829,7 +851,7 @@ open(table) } else if recordType == CKRecord.SystemType.share { withErrorReporting { - try deleteShare(recordID: recordID, recordType: recordType) + try deleteShare(recordID: recordID) } } else { // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? @@ -937,18 +959,18 @@ } } - private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { - // TODO: more efficient way to do this? + private func deleteShare(recordID: CKRecord.ID) throws { try userDatabase.write { db in - let metadata = + let shareAndRecordName = try SyncMetadata - .where { $0.share.isNot(nil) } + .where(\.isShared) + .select { ($0.share, $0.recordName) } .fetchAll(db) - .first(where: { $0.share?.recordID == recordID }) ?? nil - guard let metadata + .first(where: { share, _ in share?.recordID == recordID }) ?? nil + guard let (_, recordName) = shareAndRecordName else { return } try SyncMetadata - .where { $0.recordName.eq(metadata.recordName) } + .where { $0.recordName.eq(recordName) } .update { $0.share = nil } .execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index bf356020..5bd0b2b4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -123,6 +123,137 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareDelieveredBeforeRecord() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) + ) + remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let share = CKShare.init( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "Share-\(UUID(1).uuidString.lowercased())", + zoneID: externalZoneID + ) + ) + + await syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/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: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Get milk" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + title: "Personal" + ) + ] + ) + ) + """ + } + + let metadata = try await userDatabase.read { db in + try SyncMetadata.order(by: \.recordName).fetchAll(db) + } + assertInlineSnapshot(of: metadata, as: .customDump) { + """ + [ + [0]: SyncMetadata( + recordPrimaryKey: "00000000-0000-0000-0000-000000000001", + recordType: "reminders", + recordName: "00000000-0000-0000-0000-000000000001:reminders", + parentRecordPrimaryKey: "00000000-0000-0000-0000-000000000001", + parentRecordType: "remindersLists", + parentRecordName: "00000000-0000-0000-0000-000000000001: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: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + remindersListID: "00000000-0000-0000-0000-000000000001", + title: "Get milk" + ), + share: nil, + isShared: false, + userModificationDate: Date(1970-01-01T00:01:00.000Z) + ), + [1]: SyncMetadata( + recordPrimaryKey: "00000000-0000-0000-0000-000000000001", + recordType: "remindersLists", + recordName: "00000000-0000-0000-0000-000000000001: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-00000000-0000-0000-0000-000000000001/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-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + title: "Personal" + ), + share: nil, + isShared: false, + userModificationDate: Date(1970-01-01T00:00:00.000Z) + ) + ] + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareeCreatesMultipleChildModels() async throws { let externalZoneID = CKRecordZone.ID( From 6f2394b1561d9a39be9578381ed68bfcefe7497c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 11 Jul 2025 19:54:45 -0700 Subject: [PATCH 361/581] Revert "Fixed a couple of todos." This reverts commit 166ce30b89dc2741e6142755e2b591ba83b34631. --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 66 +++------ .../CloudKitTests/SharingTests.swift | 131 ------------------ 3 files changed, 23 insertions(+), 176 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 15cf694d..0560f673 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -142,7 +142,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "main", - "revision" : "2d9f1d94f5cbfbd37c5ab0b2500b385db95516a3" + "revision" : "a6bc6d56fc967c3956fce8c25bd6f051ac6934af" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d5890ca1..46b34709 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -418,7 +418,6 @@ ) } - // TODO: Possible to get test coverage on this? package func acceptShare(metadata: ShareMetadata) async throws { guard let metadata = metadata.rawValue else { @@ -431,13 +430,8 @@ return } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) - let share = try await container.accept(metadata) - try await userDatabase.write { db in - try SyncMetadata - .where { $0.recordName.eq(rootRecordID.recordName) } - .update { $0.share = share } - .execute(db) - } + // TODO: do something with the CKShare returned? save it in SyncMetadata? + _ = try await container.accept(metadata) try await syncEngines.shared?.fetchChanges( .init( scope: .zoneIDs([rootRecordID.zoneID]), @@ -792,39 +786,23 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { - enum ShareOrReference { - case share(CKShare) - case reference(CKShare.Reference) - } - var shares: [ShareOrReference] = [] + // TODO: If a CKShare comes in before a CKRecord with a share, then the cacheShare will not write anything + let shares: [CKShare] = [] for record in modifications { if let share = record as? CKShare { - shares.append(.share(share)) + await withErrorReporting { + try await cacheShare(share) + } } else { upsertFromServerRecord(record) - if let shareReference = record.share { - shares.append(.reference(shareReference)) - } } - } - - await withTaskGroup(of: Void.self) { group in - for share in shares { - group.addTask { - switch share { - case .share(let share): - await withErrorReporting { - try await self.cacheShare(share) - } - case .reference(let shareReference): - guard - let record = try? await syncEngine.database.record(for: shareReference.recordID), - let share = record as? CKShare - else { return } - await withErrorReporting { - try await self.cacheShare(share) - } - } + if let shareReference = record.share, + // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in + let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), + let share = shareRecord as? CKShare + { + await withErrorReporting { + try await cacheShare(share) } } } @@ -851,7 +829,7 @@ open(table) } else if recordType == CKRecord.SystemType.share { withErrorReporting { - try deleteShare(recordID: recordID) + try deleteShare(recordID: recordID, recordType: recordType) } } else { // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? @@ -959,18 +937,18 @@ } } - private func deleteShare(recordID: CKRecord.ID) throws { + private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { + // TODO: more efficient way to do this? try userDatabase.write { db in - let shareAndRecordName = + let metadata = try SyncMetadata - .where(\.isShared) - .select { ($0.share, $0.recordName) } + .where { $0.share.isNot(nil) } .fetchAll(db) - .first(where: { share, _ in share?.recordID == recordID }) ?? nil - guard let (_, recordName) = shareAndRecordName + .first(where: { $0.share?.recordID == recordID }) ?? nil + guard let metadata else { return } try SyncMetadata - .where { $0.recordName.eq(recordName) } + .where { $0.recordName.eq(metadata.recordName) } .update { $0.share = nil } .execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 5bd0b2b4..bf356020 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -123,137 +123,6 @@ extension BaseCloudKitTests { } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func shareDelieveredBeforeRecord() async throws { - let externalZoneID = CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) - ) - remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) - remindersListRecord.setValue(false, forKey: "isCompleted", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - let share = CKShare.init( - rootRecord: remindersListRecord, - shareID: CKRecord.ID( - recordName: "Share-\(UUID(1).uuidString.lowercased())", - zoneID: externalZoneID - ) - ) - - await syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/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: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Get milk" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - title: "Personal" - ) - ] - ) - ) - """ - } - - let metadata = try await userDatabase.read { db in - try SyncMetadata.order(by: \.recordName).fetchAll(db) - } - assertInlineSnapshot(of: metadata, as: .customDump) { - """ - [ - [0]: SyncMetadata( - recordPrimaryKey: "00000000-0000-0000-0000-000000000001", - recordType: "reminders", - recordName: "00000000-0000-0000-0000-000000000001:reminders", - parentRecordPrimaryKey: "00000000-0000-0000-0000-000000000001", - parentRecordType: "remindersLists", - parentRecordName: "00000000-0000-0000-0000-000000000001: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: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", - title: "Get milk" - ), - share: nil, - isShared: false, - userModificationDate: Date(1970-01-01T00:01:00.000Z) - ), - [1]: SyncMetadata( - recordPrimaryKey: "00000000-0000-0000-0000-000000000001", - recordType: "remindersLists", - recordName: "00000000-0000-0000-0000-000000000001: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-00000000-0000-0000-0000-000000000001/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-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - title: "Personal" - ), - share: nil, - isShared: false, - userModificationDate: Date(1970-01-01T00:00:00.000Z) - ) - ] - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareeCreatesMultipleChildModels() async throws { let externalZoneID = CKRecordZone.ID( From fab5f69d4ff81852ae4923ffb6bf8b9dfb873d83 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 11 Jul 2025 19:46:20 -0700 Subject: [PATCH 362/581] Fixed a couple of todos. wip --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 66 ++++++++----- .../CloudKitTests/SharingTests.swift | 94 +++++++++++++++++++ 3 files changed, 139 insertions(+), 23 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0560f673..15cf694d 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -142,7 +142,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "main", - "revision" : "a6bc6d56fc967c3956fce8c25bd6f051ac6934af" + "revision" : "2d9f1d94f5cbfbd37c5ab0b2500b385db95516a3" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 46b34709..d5890ca1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -418,6 +418,7 @@ ) } + // TODO: Possible to get test coverage on this? package func acceptShare(metadata: ShareMetadata) async throws { guard let metadata = metadata.rawValue else { @@ -430,8 +431,13 @@ return } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) - // TODO: do something with the CKShare returned? save it in SyncMetadata? - _ = try await container.accept(metadata) + let share = try await container.accept(metadata) + try await userDatabase.write { db in + try SyncMetadata + .where { $0.recordName.eq(rootRecordID.recordName) } + .update { $0.share = share } + .execute(db) + } try await syncEngines.shared?.fetchChanges( .init( scope: .zoneIDs([rootRecordID.zoneID]), @@ -786,23 +792,39 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { - // TODO: If a CKShare comes in before a CKRecord with a share, then the cacheShare will not write anything - let shares: [CKShare] = [] + enum ShareOrReference { + case share(CKShare) + case reference(CKShare.Reference) + } + var shares: [ShareOrReference] = [] for record in modifications { if let share = record as? CKShare { - await withErrorReporting { - try await cacheShare(share) - } + shares.append(.share(share)) } else { upsertFromServerRecord(record) + if let shareReference = record.share { + shares.append(.reference(shareReference)) + } } - if let shareReference = record.share, - // TODO: do this in parallel to not hold everything up? i think this is the cause of records staggering in - let shareRecord = try? await syncEngine.database.record(for: shareReference.recordID), - let share = shareRecord as? CKShare - { - await withErrorReporting { - try await cacheShare(share) + } + + await withTaskGroup(of: Void.self) { group in + for share in shares { + group.addTask { + switch share { + case .share(let share): + await withErrorReporting { + try await self.cacheShare(share) + } + case .reference(let shareReference): + guard + let record = try? await syncEngine.database.record(for: shareReference.recordID), + let share = record as? CKShare + else { return } + await withErrorReporting { + try await self.cacheShare(share) + } + } } } } @@ -829,7 +851,7 @@ open(table) } else if recordType == CKRecord.SystemType.share { withErrorReporting { - try deleteShare(recordID: recordID, recordType: recordType) + try deleteShare(recordID: recordID) } } else { // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? @@ -937,18 +959,18 @@ } } - private func deleteShare(recordID: CKRecord.ID, recordType: String) throws { - // TODO: more efficient way to do this? + private func deleteShare(recordID: CKRecord.ID) throws { try userDatabase.write { db in - let metadata = + let shareAndRecordName = try SyncMetadata - .where { $0.share.isNot(nil) } + .where(\.isShared) + .select { ($0.share, $0.recordName) } .fetchAll(db) - .first(where: { $0.share?.recordID == recordID }) ?? nil - guard let metadata + .first(where: { share, _ in share?.recordID == recordID }) ?? nil + guard let (_, recordName) = shareAndRecordName else { return } try SyncMetadata - .where { $0.recordName.eq(metadata.recordName) } + .where { $0.recordName.eq(recordName) } .update { $0.share = nil } .execute(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index bf356020..42338984 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -123,6 +123,100 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareDelieveredBeforeRecord() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) + ) + remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let share = CKShare.init( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "Share-\(UUID(1).uuidString.lowercased())", + zoneID: externalZoneID + ) + ) + + await syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + + await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/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-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + title: "Personal" + ) + ] + ) + ) + """ + } + + let metadata = try await userDatabase.read { db in + try SyncMetadata.order(by: \.recordName).fetchAll(db) + } + assertInlineSnapshot(of: metadata, as: .customDump) { + """ + [ + [0]: SyncMetadata( + recordPrimaryKey: "00000000-0000-0000-0000-000000000001", + recordType: "remindersLists", + recordName: "00000000-0000-0000-0000-000000000001: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-00000000-0000-0000-0000-000000000001/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-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), + id: "00000000-0000-0000-0000-000000000001", + isCompleted: 0, + title: "Personal" + ), + share: nil, + isShared: false, + userModificationDate: Date(1970-01-01T00:00:00.000Z) + ) + ] + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareeCreatesMultipleChildModels() async throws { let externalZoneID = CKRecordZone.ID( From cd16499539e152d13972959faccacfe81fb2dfa6 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 11 Jul 2025 18:13:33 -0700 Subject: [PATCH 363/581] wip --- Package.resolved | 6 +++--- Sources/SharingGRDBCore/CloudKit/Triggers.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Package.resolved b/Package.resolved index 83f46bee..fa43f9c2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "d1691545d53581400b1de9b0472d45eb25c19fed", - "version" : "1.4.4" + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" } }, { @@ -124,7 +124,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "main", - "revision" : "fb29d5a6d35f5e57e1479d1da6ad51d8d6f23f66" + "revision" : "2d9f1d94f5cbfbd37c5ab0b2500b385db95516a3" } }, { diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 9b3c8fb0..591f4af0 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -66,9 +66,9 @@ extension SyncMetadata { } onConflict: { ($0.recordPrimaryKey, $0.recordType) } doUpdate: { - $0.parentRecordPrimaryKey = SQLQueryExpression(#""excluded"."parentRecordPrimaryKey""#) - $0.parentRecordType = SQLQueryExpression(#""excluded"."parentRecordType""#) - $0.userModificationDate = SQLQueryExpression(#""excluded"."userModificationDate""#) + $0.parentRecordPrimaryKey = $1.parentRecordPrimaryKey + $0.parentRecordType = $1.parentRecordType + $0.userModificationDate = $1.userModificationDate } } } From 38192f0b19beace46579ee11ffb4c400bc1a8ccc Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 11 Jul 2025 18:14:53 -0700 Subject: [PATCH 364/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 12 ++++++------ Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index aa3ec4b5..73671181 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -197,9 +197,9 @@ extension CKRecord { } package func update(with row: T, userModificationDate: Date) { - for column in T.TableColumns.allColumns { - func open(_ column: some TableColumnExpression) { - let column = column as! any TableColumnExpression + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let column = column as! any WritableTableColumnExpression let value = Value(queryOutput: row[keyPath: column.keyPath]) switch value.queryBinding { case .blob(let value): @@ -236,10 +236,10 @@ extension CKRecord { typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable self.userModificationDate = other.userModificationDate - for column in T.TableColumns.allColumns { - func open(_ column: some TableColumnExpression) { + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { let key = column.name - let column = column as! any TableColumnExpression + let column = column as! any WritableTableColumnExpression let didSet: Bool if let value = other[key] as? CKAsset { didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 46b34709..5917e10e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -323,7 +323,7 @@ let query = try await updateQuery( for: T.self, record: lastKnownServerRecord, - columnNames: T.TableColumns.allColumns.map(\.name), + columnNames: T.TableColumns.writableColumns.map(\.name), changedColumnNames: changedColumns ) try await userDatabase.write { db in @@ -994,7 +994,7 @@ metadata?.userModificationDate ?? serverRecord.userModificationDate func open>(_: T.Type) throws { - var columnNames = T.TableColumns.allColumns.map(\.name) + var columnNames = T.TableColumns.writableColumns.map(\.name) if let metadata, let allFields { let row = try userDatabase.read { db in try T.find(SQLQueryExpression("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) From 8c98ee849034da9b7cea6adbae4604fa362888b1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 12 Jul 2025 09:27:52 -0700 Subject: [PATCH 365/581] Emulate cloud database transactions. --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 53 +++++++++--------- .../CloudKitTests/AssetsTests.swift | 2 +- .../MockCloudDatabaseTests.swift | 34 ++++++++++++ .../Internal/CloudKitTestHelpers.swift | 54 ++++++++++++++++++- 4 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5917e10e..1c1fd99e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -89,13 +89,13 @@ privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { try validateSchema(tables: tables, userDatabase: userDatabase) - // TODO: Explain why / link to documentation? - precondition( - !userDatabase.configuration.foreignKeysEnabled, - """ - Foreign key support must be disabled to synchronize with CloudKit. - """ - ) +// // TODO: Explain why / link to documentation? +// precondition( +// !userDatabase.configuration.foreignKeysEnabled, +// """ +// Foreign key support must be disabled to synchronize with CloudKit. +// """ +// ) self.container = container self.defaultSyncEngines = defaultSyncEngines self.userDatabase = userDatabase @@ -548,17 +548,17 @@ switch (lhs, rhs) { case (.saveRecord(let lhs), .saveRecord(let rhs)): guard - let lhsRecordType = lhs.recordType, + let lhsRecordType = lhs.tableName, let lhsIndex = tablesByOrder[lhsRecordType], - let rhsRecordType = rhs.recordType, + let rhsRecordType = rhs.tableName, let rhsIndex = tablesByOrder[rhsRecordType] else { return true } return lhsIndex < rhsIndex case (.deleteRecord(let lhs), .deleteRecord(let rhs)): guard - let lhsRecordType = lhs.recordType, + let lhsRecordType = lhs.tableName, let lhsIndex = tablesByOrder[lhsRecordType], - let rhsRecordType = rhs.recordType, + let rhsRecordType = rhs.tableName, let rhsIndex = tablesByOrder[rhsRecordType] else { return true } return lhsIndex > rhsIndex @@ -786,6 +786,16 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { + let modifications = modifications.sorted { lhs, rhs in + guard + let lhsRecordType = lhs.recordID.tableName, + let lhsIndex = tablesByOrder[lhsRecordType], + let rhsRecordType = rhs.recordID.tableName, + let rhsIndex = tablesByOrder[rhsRecordType] + else { return true } + return lhsIndex < rhsIndex + } + // TODO: If a CKShare comes in before a CKRecord with a share, then the cacheShare will not write anything let shares: [CKShare] = [] for record in modifications { @@ -832,14 +842,7 @@ try deleteShare(recordID: recordID, recordType: recordType) } } else { - // TODO: Should we be reporting this? What if another device deletes from a table this device doesn't know about? - reportIssue( - .sqliteDataCloudKitFailure.appending( - """ - : No table to delete from: "\(recordType)" - """ - ) - ) + // NB: Deleting a record from a table we do not currently recognize. } } } @@ -893,8 +896,11 @@ case .serverRejectedRequest: clearServerRecord() + case .referenceViolation: + reportIssue("Reference violation") + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, - .notAuthenticated, .referenceViolation, .operationCancelled, .batchRequestFailed, + .notAuthenticated, .operationCancelled, .batchRequestFailed, .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, .permissionFailure, .invalidArguments, .resultsTruncated, .assetFileNotFound, .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired, @@ -966,7 +972,7 @@ recordPrimaryKey: recordPrimaryKey, recordType: serverRecord.recordType, parentRecordPrimaryKey: serverRecord.parent?.recordID.recordPrimaryKey, - parentRecordType: serverRecord.parent?.recordID.recordType, + parentRecordType: serverRecord.parent?.recordID.tableName, lastKnownServerRecord: serverRecord, _lastKnownServerRecordAllFields: serverRecord, share: nil, @@ -1137,7 +1143,7 @@ } extension CKRecord.ID { - var recordType: String? { + var tableName: String? { guard let i = recordName.utf8.lastIndex(of: .init(ascii: ":")), let j = recordName.utf8.index(i, offsetBy: 1, limitedBy: recordName.utf8.endIndex) @@ -1149,8 +1155,7 @@ var recordPrimaryKey: String? { guard - let i = recordName.utf8.lastIndex(of: .init(ascii: ":")), - let j = recordName.utf8.index(i, offsetBy: 1, limitedBy: recordName.utf8.endIndex) + let i = recordName.utf8.lastIndex(of: .init(ascii: ":")) else { return nil } let recordPrimaryKeyBytes = recordName.utf8[.. (any Error)? in + guard case .failure(let error) = result + else { return nil } + return error + } + let deleteErrors = deleteResults.compactMapValues { result -> (any Error)? in + guard case .failure(let error) = result + else { return nil } + return error + } + if !saveErrors.isEmpty || !deleteErrors.isEmpty { + var message = "Error modifying records:\n" + if !saveErrors.isEmpty { + message.append("\nSave errors:\n") + message.append( + saveErrors.keys.sorted(by: { $0.recordName < $1.recordName }) + .map { key in + """ + - \(key) + \(saveErrors[key]!) + """ + } + .joined(separator: "\n") + ) + } + if !deleteErrors.isEmpty { + message.append("\n\nDelete errors:\n") + message.append( + deleteErrors.keys.sorted(by: { $0.recordName < $1.recordName }) + .map { key in + """ + - \(key) + \(deleteErrors[key]!) + """ + } + .joined(separator: "\n") + ) + } + reportIssue(message) + } + return ModifyRecordsCallback { await syncEngine.delegate.handleEvent( .fetchedRecordZoneChanges( From 704eb913244925c25db00504ec8378ded25d79e4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 12 Jul 2025 09:29:20 -0700 Subject: [PATCH 366/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 1c1fd99e..b582b961 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -89,13 +89,13 @@ privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { try validateSchema(tables: tables, userDatabase: userDatabase) -// // TODO: Explain why / link to documentation? -// precondition( -// !userDatabase.configuration.foreignKeysEnabled, -// """ -// Foreign key support must be disabled to synchronize with CloudKit. -// """ -// ) + // TODO: Explain why / link to documentation? + precondition( + !userDatabase.configuration.foreignKeysEnabled, + """ + Foreign key support must be disabled to synchronize with CloudKit. + """ + ) self.container = container self.defaultSyncEngines = defaultSyncEngines self.userDatabase = userDatabase @@ -786,16 +786,6 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { - let modifications = modifications.sorted { lhs, rhs in - guard - let lhsRecordType = lhs.recordID.tableName, - let lhsIndex = tablesByOrder[lhsRecordType], - let rhsRecordType = rhs.recordID.tableName, - let rhsIndex = tablesByOrder[rhsRecordType] - else { return true } - return lhsIndex < rhsIndex - } - // TODO: If a CKShare comes in before a CKRecord with a share, then the cacheShare will not write anything let shares: [CKShare] = [] for record in modifications { From 08af0504581a92ba2eca5cd60514306224bd3019 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 12 Jul 2025 09:40:44 -0700 Subject: [PATCH 367/581] wip --- .../MockCloudDatabaseTests.swift | 43 +++++++++++++++++++ .../Internal/CloudKitTestHelpers.swift | 41 ------------------ 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index 49a1d001..0e948a78 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -19,6 +19,34 @@ extension BaseCloudKitTests { child.parent = CKRecord.Reference(record: parent, action: .none) await syncEngine.modifyRecords(scope: .private, saving: [child, parent]) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__), + recordType: "A", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(B/_defaultZone/__defaultOwner__), + recordType: "B", + parent: CKReference(recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__)), + share: nil + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -29,6 +57,21 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords(scope: .private, saving: [child, parent]) await syncEngine.modifyRecords(scope: .private, deleting: [parent.recordID, child.recordID]) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } } } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index d77988b3..56d8097e 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -588,47 +588,6 @@ extension SyncEngine { deleting: recordIDsToDelete ) - let saveErrors = saveResults.compactMapValues { result -> (any Error)? in - guard case .failure(let error) = result - else { return nil } - return error - } - let deleteErrors = deleteResults.compactMapValues { result -> (any Error)? in - guard case .failure(let error) = result - else { return nil } - return error - } - if !saveErrors.isEmpty || !deleteErrors.isEmpty { - var message = "Error modifying records:\n" - if !saveErrors.isEmpty { - message.append("\nSave errors:\n") - message.append( - saveErrors.keys.sorted(by: { $0.recordName < $1.recordName }) - .map { key in - """ - - \(key) - \(saveErrors[key]!) - """ - } - .joined(separator: "\n") - ) - } - if !deleteErrors.isEmpty { - message.append("\n\nDelete errors:\n") - message.append( - deleteErrors.keys.sorted(by: { $0.recordName < $1.recordName }) - .map { key in - """ - - \(key) - \(deleteErrors[key]!) - """ - } - .joined(separator: "\n") - ) - } - reportIssue(message) - } - return ModifyRecordsCallback { await syncEngine.delegate.handleEvent( .fetchedRecordZoneChanges( From 7b5da954937253772795d3ee3c001cbb9dd65ef6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 12 Jul 2025 09:41:12 -0700 Subject: [PATCH 368/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b582b961..3cc97f98 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -886,11 +886,8 @@ case .serverRejectedRequest: clearServerRecord() - case .referenceViolation: - reportIssue("Reference violation") - case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, - .notAuthenticated, .operationCancelled, .batchRequestFailed, + .notAuthenticated, .referenceViolation, .operationCancelled, .batchRequestFailed, .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, .permissionFailure, .invalidArguments, .resultsTruncated, .assetFileNotFound, .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired, From 98d57a1f72ef3deab005df64b7a007cf159c858b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 12 Jul 2025 09:44:37 -0700 Subject: [PATCH 369/581] wip --- .../MockCloudDatabaseTests.swift | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index 0e948a78..67f83f25 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -49,6 +49,30 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveTransaction_ChildNoParent() async throws { + let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) + child.parent = CKRecord.Reference(record: parent, action: .none) + + await syncEngine.modifyRecords(scope: .private, saving: [child]) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteTransaction_ParentBeforeChild() async throws { let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) @@ -73,5 +97,43 @@ extension BaseCloudKitTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteTransaction_DeleteParentButNotChild() async throws { + let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) + child.parent = CKRecord.Reference(record: parent, action: .none) + + await syncEngine.modifyRecords(scope: .private, saving: [child, parent]) + await syncEngine.modifyRecords(scope: .private, deleting: [parent.recordID]) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__), + recordType: "A", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(B/_defaultZone/__defaultOwner__), + recordType: "B", + parent: CKReference(recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__)), + share: nil + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } } From b502af030289583dce11ad4c85f92e5c4ba21cdd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 14 Jul 2025 11:27:22 -0700 Subject: [PATCH 370/581] clean up --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 +------- Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift | 3 ++- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d5890ca1..f3acaf16 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -431,13 +431,7 @@ return } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) - let share = try await container.accept(metadata) - try await userDatabase.write { db in - try SyncMetadata - .where { $0.recordName.eq(rootRecordID.recordName) } - .update { $0.share = share } - .execute(db) - } + _ = try await container.accept(metadata) try await syncEngines.shared?.fetchChanges( .init( scope: .zoneIDs([rootRecordID.zoneID]), diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 42338984..74e02f2c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -146,7 +146,8 @@ extension BaseCloudKitTests { ) ) - await syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + await syncEngine.modifyRecords(scope: .shared, saving: [share]) + await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]) await syncEngine.processBatch() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { From 8855fc2048c511126bc1bf7f2928494e8c94a628 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 11:33:28 -0700 Subject: [PATCH 371/581] cleanup --- .../Internal/CloudKitTestHelpers.swift | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 56d8097e..aead95a3 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -301,15 +301,12 @@ final class MockCloudDatabase: CloudDatabase { let existingRecord = storage[recordToSave.recordID] func saveRecordToDatabase() { - let parentRecordExists = recordToSave.parent == nil || storage.values.contains { record in - record.recordID != recordToSave.recordID - && recordToSave.parent?.recordID == record.recordID + let hasReferenceViolation = recordToSave.parent.map { parent in + storage[parent.recordID] == nil + && !recordsToSave.contains { $0.recordID == parent.recordID } } - || recordsToSave.contains { record in - record.recordID != recordToSave.recordID - && recordToSave.parent?.recordID == record.recordID - } - guard parentRecordExists + ?? false + guard !hasReferenceViolation else { saveResults[recordToSave.recordID] = .failure(CKError(.referenceViolation)) return @@ -355,11 +352,11 @@ final class MockCloudDatabase: CloudDatabase { // exists in the DB. This means the user has created a new CKRecord from scratch, // giving it a new identity, rather than leveraging an existing CKRecord. Issue.record( - """ - A new identity was created for an existing 'CKRecord'. Rather than creating - 'CKRecord' from scratch for an existing record, use the database to fetch the - current record. - """ + """ + A new identity was created for an existing 'CKRecord'. Rather than creating \ + 'CKRecord' from scratch for an existing record, use the database to fetch the \ + current record. + """ ) saveResults[recordToSave.recordID] = .failure( CKError( @@ -386,18 +383,13 @@ final class MockCloudDatabase: CloudDatabase { fatalError() } for recordIDToDelete in recordIDsToDelete { - let recordExistsReferencingRecordToDelete = - // NB: Storage does not contain the parent of the record we are deleting - storage.values.contains { record in - record.recordID != recordIDToDelete - && record.parent?.recordID == recordIDToDelete - } - // NB: Records we are deleting does not contain parent of this record - && !recordIDsToDelete.contains { recordID in - recordID != recordIDToDelete - && storage[recordID]?.parent?.recordID == recordIDToDelete - } - guard !recordExistsReferencingRecordToDelete + let hasReferenceViolation = !Set( + storage.values.compactMap { $0.parent?.recordID == recordIDToDelete ? $0.recordID : nil } + ) + .subtracting(recordIDsToDelete) + .isEmpty + + guard !hasReferenceViolation else { deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) continue From f3a9c5a3c0942b9e8056736b3994ac07b381ea6c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 13:15:00 -0700 Subject: [PATCH 372/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../IdentifierStringConvertible.swift | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/IdentifierStringConvertible.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 73671181..ef431e4b 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -228,7 +228,7 @@ extension CKRecord { } } - package func update>( + package func update( with other: CKRecord, row: T, columnNames: inout [String] diff --git a/Sources/SharingGRDBCore/CloudKit/IdentifierStringConvertible.swift b/Sources/SharingGRDBCore/CloudKit/IdentifierStringConvertible.swift new file mode 100644 index 00000000..5c3b0d8b --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/IdentifierStringConvertible.swift @@ -0,0 +1,52 @@ +import Foundation + +public protocol IdentifierStringConvertible { + init?(rawIdentifier: String) + var rawIdentifier: String { get } +} + +extension IdentifierStringConvertible where Self: CustomStringConvertible { + public var rawIdentifier: String { description } +} + +extension IdentifierStringConvertible where Self: LosslessStringConvertible { + public init?(rawIdentifier: String) { + self.init(rawIdentifier) + } +} + +extension Bool: IdentifierStringConvertible {} +extension Character: IdentifierStringConvertible {} +extension Double: IdentifierStringConvertible {} +extension Float: IdentifierStringConvertible {} +@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) +extension Float16: IdentifierStringConvertible {} +#if !(os(Windows) || os(Android) || ($Embedded && !os(Linux) && !(os(macOS) || os(iOS) || os(watchOS) || os(tvOS)))) && (arch(i386) || arch(x86_64)) +extension Float80: IdentifierStringConvertible {} +#endif +extension Int: IdentifierStringConvertible {} +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, *) +extension Int128: IdentifierStringConvertible {} +extension Int16: IdentifierStringConvertible {} +extension Int32: IdentifierStringConvertible {} +extension Int64: IdentifierStringConvertible {} +extension Int8: IdentifierStringConvertible {} +extension String: IdentifierStringConvertible {} +extension Substring: IdentifierStringConvertible {} +extension UInt: IdentifierStringConvertible {} +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, *) +extension UInt128: IdentifierStringConvertible {} +extension UInt16: IdentifierStringConvertible {} +extension UInt32: IdentifierStringConvertible {} +extension UInt64: IdentifierStringConvertible {} +extension UInt8: IdentifierStringConvertible {} +extension Unicode.Scalar: IdentifierStringConvertible {} + +extension UUID: IdentifierStringConvertible { + public init?(rawIdentifier: String) { + self.init(uuidString: rawIdentifier) + } + public var rawIdentifier: String { + description.lowercased() + } +} From a476f10f4575deea58d61a8e068e73e0f94b5c9e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 13:17:23 -0700 Subject: [PATCH 373/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 1d323ce1..29162960 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -120,12 +120,12 @@ extension SyncMetadata { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTable { +extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. /// /// - Parameter id: The ID of the record. - public static func recordName(for id: UUID) -> String { - "\(id.uuidString.lowercased()):\(tableName)" + public static func recordName(for id: PrimaryKey.QueryOutput) -> String { + "\(id.rawIdentifier):\(tableName)" } var recordName: String { @@ -134,7 +134,7 @@ extension PrimaryKeyedTable { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTableDefinition { +extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { public var recordName: some QueryExpression { SQLQueryExpression(" \(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") } From aab1ce9c0d66b6a36e886de07107deeb0522e7d7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 13:18:03 -0700 Subject: [PATCH 374/581] wip --- Sources/SharingGRDBCore/CloudKit/Triggers.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 591f4af0..3e862c4b 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -3,7 +3,7 @@ import CloudKit import Foundation @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTable { +extension PrimaryKeyedTable { static func metadataTriggers(parentForeignKey: ForeignKey?) -> [TemporaryTrigger] { [ afterInsert(parentForeignKey: parentForeignKey), @@ -46,7 +46,7 @@ extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - fileprivate static func upsert>( + fileprivate static func upsert( new: TemporaryTrigger.Operation.New, parentForeignKey: ForeignKey?, ) -> some StructuredQueriesCore.Statement { From 9a0284d57493e772d21a40ba750feb2bf70fa27e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 13:18:40 -0700 Subject: [PATCH 375/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5917e10e..c9a65d5a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1500,7 +1500,7 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - private func upsert>( + private func upsert( _: T.Type, record: CKRecord, columnNames: some Collection From 18a05a9838c3faf7916034def6c3a2fa9917acba Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 13:20:33 -0700 Subject: [PATCH 376/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c9a65d5a..c6654e22 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -646,7 +646,7 @@ missingTable = recordID return nil } - func open>(_: T.Type) async -> CKRecord? { + func open(_: T.Type) async -> CKRecord? { let row = withErrorReporting { try userDatabase.read { db in @@ -711,7 +711,8 @@ for table in tables { withErrorReporting(.sqliteDataCloudKitFailure) { let recordNames = try userDatabase.read { db in - func open>(_: T.Type) throws -> [String] { + func open(_: T.Type) throws -> [String] + where T.PrimaryKey.QueryOutput: IdentifierStringConvertible { try T .select(\.primaryKey) .fetchAll(db) @@ -810,7 +811,7 @@ // TODO: Group by recordType and delete in batches for (recordID, recordType) in deletions { if let table = tablesByName[recordType] { - func open>(_: T.Type) { + func open(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in try T @@ -993,7 +994,7 @@ serverRecord.userModificationDate = metadata?.userModificationDate ?? serverRecord.userModificationDate - func open>(_: T.Type) throws { + func open(_: T.Type) throws { var columnNames = T.TableColumns.writableColumns.map(\.name) if let metadata, let allFields { let row = try userDatabase.read { db in @@ -1062,7 +1063,7 @@ ?? nil } - private func updateQuery>( + private func updateQuery( for _: T.Type, record: CKRecord, columnNames: some Collection, From a25f75f882563db079650e1d1550eaf0a690d6b7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 13:21:26 -0700 Subject: [PATCH 377/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c6654e22..bd72dc80 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -265,7 +265,7 @@ ? foreignKeysByTableName[tableName]?.first : nil - func open>(_: T.Type) throws { + func open(_: T.Type) throws { let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = parentForeignKey .map { ("\(T.self).\(quote: $0.from)", "\(bind: $0.table)") } @@ -306,7 +306,7 @@ for (tableName, currentRecordType) in tablesWithChangedSchemas { guard let table = tablesByName[tableName] else { continue } - func open>(_: T.Type) async throws { + func open(_: T.Type) async throws { let previousRecordType = previousRecordTypeByTableName[tableName] let changedColumns = currentRecordType.tableInfo.subtracting( previousRecordType?.tableInfo ?? [] From 7e6c119fc935b27605bb6410d56c6f4c9bf27431 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 13:22:05 -0700 Subject: [PATCH 378/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index bd72dc80..f6367a01 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -445,11 +445,11 @@ } } - extension PrimaryKeyedTable { + extension PrimaryKeyedTable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) fileprivate static func createTriggers( foreignKeysByTableName: [String: [ForeignKey]], - tablesByName: [String: any PrimaryKeyedTable.Type], + tablesByName: [String: any PrimaryKeyedTable.Type], db: Database ) throws { let parentForeignKey = From 980fbecceae3385fa42a3f7e9ce7a8324c6ff99e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 13:31:07 -0700 Subject: [PATCH 379/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 30 +++++++++---------- .../CloudKit/SyncMetadata.swift | 7 +++++ .../Internal/BaseCloudKitTests.swift | 6 ++-- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index f6367a01..4f5f0f42 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -13,9 +13,9 @@ package let userDatabase: UserDatabase package let logger: Logger package let metadatabase: any DatabaseReader - package let tables: [any PrimaryKeyedTable.Type] - package let privateTables: [any PrimaryKeyedTable.Type] - let tablesByName: [String: any PrimaryKeyedTable.Type] + package let tables: [any PrimaryKeyedTable.Type] + package let privateTables: [any PrimaryKeyedTable.Type] + let tablesByName: [String: any PrimaryKeyedTable.Type] private let tablesByOrder: [String: Int] let foreignKeysByTableName: [String: [ForeignKey]] package let syncEngines = LockIsolated(SyncEngines()) @@ -30,8 +30,8 @@ container: CKContainer, database: any DatabaseWriter, logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { let userDatabase = UserDatabase(database: database) try self.init( @@ -85,8 +85,8 @@ userDatabase: UserDatabase, logger: Logger, metadatabaseURL: URL, - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { try validateSchema(tables: tables, userDatabase: userDatabase) // TODO: Explain why / link to documentation? @@ -711,12 +711,10 @@ for table in tables { withErrorReporting(.sqliteDataCloudKitFailure) { let recordNames = try userDatabase.read { db in - func open(_: T.Type) throws -> [String] - where T.PrimaryKey.QueryOutput: IdentifierStringConvertible { + func open(_: T.Type) throws -> [String] { try T - .select(\.primaryKey) + .select(\._recordName) .fetchAll(db) - .map { T.recordName(for: $0) } } return try open(table) } @@ -1424,8 +1422,8 @@ } private struct HashablePrimaryKeyedTableType: Hashable { - let type: any PrimaryKeyedTable.Type - init(_ type: any PrimaryKeyedTable.Type) { + let type: any PrimaryKeyedTable.Type + init(_ type: any PrimaryKeyedTable.Type) { self.type = type } func hash(into hasher: inout Hasher) { @@ -1439,11 +1437,11 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func tablesByOrder( userDatabase: UserDatabase, - tables: [any PrimaryKeyedTable.Type], - tablesByName: [String: any PrimaryKeyedTable.Type] + tables: [any PrimaryKeyedTable.Type], + tablesByName: [String: any PrimaryKeyedTable.Type] ) throws -> [String: Int] { let tableDependencies = try userDatabase.read { db in - var dependencies: [HashablePrimaryKeyedTableType: [any PrimaryKeyedTable.Type]] = [:] + var dependencies: [HashablePrimaryKeyedTableType: [any PrimaryKeyedTable.Type]] = [:] for table in tables { let toTables = try SQLQueryExpression( """ diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 29162960..1c93e867 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -136,6 +136,13 @@ extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConver @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { public var recordName: some QueryExpression { + _recordName + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension PrimaryKeyedTableDefinition { + var _recordName: some QueryExpression { SQLQueryExpression(" \(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") } } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 141e57cc..4889d3d4 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -75,7 +75,7 @@ class BaseCloudKitTests: @unchecked Sendable { syncEngine.private.state.assertPendingRecordZoneChanges([]) syncEngine.private.assertAcceptedShareMetadata([]) } else { - Issue.record("Tests must be run on iOS 17+,m macOS 14+, tvOS 17+ and watchOS 10+.") + Issue.record("Tests must be run on iOS 17+, macOS 14+, tvOS 17+ and watchOS 10+.") } } } @@ -91,8 +91,8 @@ extension SyncEngine { container: any CloudContainer, userDatabase: UserDatabase, metadatabaseURL: URL, - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] + tables: [any PrimaryKeyedTable.Type], + privateTables: [any PrimaryKeyedTable.Type] = [] ) async throws { try self.init( container: container, From 692de5f7e66fc003e9f0a3a58f76234e495f8bac Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 14 Jul 2025 13:32:21 -0700 Subject: [PATCH 380/581] wip --- Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 1eb56b66..e2d9b819 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -35,7 +35,7 @@ extension SyncEngine { record: T, configure: @Sendable (CKShare) -> Void ) async throws -> SharedRecord - where T.TableColumns.PrimaryKey == UUID { + where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { guard !privateTables.contains(where: { T.self == $0 }) else { throw PrivateRootRecord() } guard let foreignKeys = foreignKeysByTableName[T.tableName] From ba3f06d29e3f44a348e3f73c09eb7d98ebbb1bf4 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 15 Jul 2025 13:50:01 -0700 Subject: [PATCH 381/581] wip --- .../CloudKitTests/AssetsTests.swift | 14 +-- .../CloudKitTests/CloudKitTests.swift | 89 ++++++++++--------- .../FetchRecordZoneChangesTests.swift | 88 +++++++++--------- .../CloudKitTests/ForeignKeyTests.swift | 22 ++--- .../CloudKitTests/MergeConflictTests.swift | 66 +++++++------- .../CloudKitTests/MetadataTests.swift | 70 +++++++-------- .../CloudKitTests/NewTableSyncTests.swift | 4 +- .../NextRecordZoneChangeBatchTests.swift | 12 +-- .../CloudKitTests/SchemaChangeTests.swift | 30 +++---- .../CloudKitTests/SharingTests.swift | 18 ++-- .../Internal/CloudKitTestHelpers.swift | 7 +- Tests/SharingGRDBTests/Internal/Schema.swift | 36 ++++++-- 12 files changed, 244 insertions(+), 212 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index 6e4e3853..d7a01edd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -20,8 +20,8 @@ extension BaseCloudKitTests { @Test func basics() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - RemindersListAsset(id: UUID(1), coverImage: Data("image".utf8), remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + RemindersListAsset(id: 1, coverImage: Data("image".utf8), remindersListID: 1) } } @@ -73,7 +73,7 @@ extension BaseCloudKitTests { } operation: { try await userDatabase.userWrite { db in try RemindersListAsset - .find(UUID(1)) + .find(RemindersListAsset.ID(1)) .update { $0.coverImage = Data("new-image".utf8) } .execute(db) } @@ -128,14 +128,14 @@ extension BaseCloudKitTests { @Test func receiveAsset() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: UUID(1)) + recordID: RemindersList.recordID(for: 1) ) remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) let remindersListAssetRecord = CKRecord( recordType: RemindersListAsset.tableName, - recordID: RemindersListAsset.recordID(for: UUID(1)) + recordID: RemindersListAsset.recordID(for: 1) ) remindersListAssetRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListAssetRecord.setValue( @@ -160,7 +160,9 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in - let remindersListAsset = try #require(try RemindersListAsset.find(UUID(1)).fetchOne(db)) + let remindersListAsset = try #require( + try RemindersListAsset.find(RemindersListAsset.ID(1)).fetchOne(db) + ) #expect(remindersListAsset.coverImage == Data("image".utf8)) } }() diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 8fa61191..07b3d36d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -453,7 +453,7 @@ extension BaseCloudKitTests { @Test func tearDown() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: 1, title: "Personal") } } await syncEngine.processBatch() @@ -499,7 +499,7 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: 1, title: "Personal") } } await syncEngine.processBatch() @@ -529,7 +529,7 @@ extension BaseCloudKitTests { let metadata = try await userDatabase.userRead { db in - try SyncMetadata.find(UUID(1), table: RemindersList.self).fetchOne(db) + try SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) } #expect(metadata != nil) } @@ -578,7 +578,7 @@ extension BaseCloudKitTests { @Test func insertUpdateDelete() async throws { try await userDatabase.userWrite { db in try RemindersList - .insert { RemindersList(id: UUID(1), title: "Personal") } + .insert { RemindersList(id: 1, title: "Personal") } .execute(db) } await syncEngine.processBatch() @@ -611,7 +611,7 @@ extension BaseCloudKitTests { } operation: { try await userDatabase.userWrite { db in try RemindersList - .find(UUID(1)) + .find(RemindersList.ID(1)) .update { $0.title = "Work" } .execute(db) } @@ -643,7 +643,7 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try RemindersList - .find(UUID(1)) + .find(RemindersList.ID(1)) .delete() .execute(db) } @@ -668,7 +668,7 @@ extension BaseCloudKitTests { @Test func remoteServerRecordUpdate() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: 1, title: "Personal") } } await syncEngine.processBatch() @@ -699,26 +699,28 @@ extension BaseCloudKitTests { let userModificationDate = try #require( try await userDatabase.userRead { db in try SyncMetadata - .find(UUID(1), table: RemindersList.self) + .find(1, table: RemindersList.self) .select(\.userModificationDate) .fetchOne(db) ?? nil } ) - let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) let serverModificationDate = userModificationDate.addingTimeInterval(60) record.setValue("Work", forKey: "title", at: serverModificationDate) _ = await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( - try { try userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), - RemindersList(id: UUID(1), title: "Work") + try { try userDatabase.userRead { db in + try RemindersList.find(RemindersList.ID(1)).fetchOne(db) } + }(), + RemindersList(id: 1, title: "Work") ) let metadata = try #require( try await userDatabase.userRead { db in try SyncMetadata - .find(UUID(1), table: RemindersList.self) + .find(1, table: RemindersList.self) .fetchOne(db) } ) @@ -752,7 +754,7 @@ extension BaseCloudKitTests { @Test func remoteServerSendsRecordWithNoChanges() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: 1, title: "Personal") } } await syncEngine.processBatch() @@ -761,11 +763,11 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in - try RemindersList.find(UUID(1)).update { $0.title = "My stuff" }.execute(db) + try RemindersList.find(RemindersList.ID(1)).update { $0.title = "My stuff" }.execute(db) } } - let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) await syncEngine.modifyRecords(scope: .private, saving: [record]) await syncEngine.processBatch() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { @@ -797,7 +799,7 @@ extension BaseCloudKitTests { @Test func remoteServerRecordUpdateWithOldRecord() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: 1, title: "Personal") } } await syncEngine.processBatch() @@ -828,27 +830,27 @@ extension BaseCloudKitTests { let userModificationDate = try #require( try await userDatabase.userRead { db in try SyncMetadata - .find(UUID(1), table: RemindersList.self) + .find(1, table: RemindersList.self) .select(\.userModificationDate) .fetchOne(db) ?? nil } ) - let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) record.encryptedValues["title"] = "Work" // NB: Manually setting '_recordChangeTag' simulates another device saving a record. record._recordChangeTag = UUID().uuidString await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( - try { try userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchOne(db) } }(), - RemindersList(id: UUID(1), title: "Personal") + try { try userDatabase.userRead { db in try RemindersList.find(RemindersList.ID(1)).fetchOne(db) } }(), + RemindersList(id: 1, title: "Personal") ) let metadata = try #require( try await userDatabase.userRead { db in try SyncMetadata - .find(UUID(1), table: RemindersList.self) + .find(1, table: RemindersList.self) .fetchOne(db) } ) @@ -882,7 +884,7 @@ extension BaseCloudKitTests { @Test func remoteServerRecordDeleted() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: 1, title: "Personal") } } await syncEngine.processBatch() @@ -910,16 +912,17 @@ extension BaseCloudKitTests { """ } - let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]) #expect( - try await userDatabase.userRead { db in try RemindersList.find(UUID(1)).fetchAll(db) } - == [] + try await userDatabase.userRead { db in + try RemindersList.find(RemindersList.ID(1)).fetchAll(db) + } == [] ) let metadata = try await userDatabase.userRead { db in try SyncMetadata - .find(UUID(1), table: RemindersList.self) + .find(1, table: RemindersList.self) .fetchOne(db) } #expect(metadata == nil) @@ -942,34 +945,34 @@ extension BaseCloudKitTests { @Test func cascadingDeletionOrder() async throws { try await userDatabase.userWrite { db in try db.seed { - Tag(id: UUID(1), title: "") - Tag(id: UUID(2), title: "") + Tag(id: 1, title: "") + Tag(id: 2, title: "") } } for _ in 1...100 { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - RemindersListPrivate(id: UUID(1), position: 1, remindersListID: UUID(1)) - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) - Reminder(id: UUID(2), title: "", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "", remindersListID: UUID(1)) - Reminder(id: UUID(4), title: "", remindersListID: UUID(1)) - ReminderTag(id: UUID(1), reminderID: UUID(1), tagID: UUID(1)) - ReminderTag(id: UUID(2), reminderID: UUID(2), tagID: UUID(1)) - ReminderTag(id: UUID(3), reminderID: UUID(3), tagID: UUID(1)) - ReminderTag(id: UUID(4), reminderID: UUID(4), tagID: UUID(1)) - ReminderTag(id: UUID(5), reminderID: UUID(1), tagID: UUID(2)) - ReminderTag(id: UUID(6), reminderID: UUID(2), tagID: UUID(2)) - ReminderTag(id: UUID(7), reminderID: UUID(3), tagID: UUID(2)) - ReminderTag(id: UUID(8), reminderID: UUID(4), tagID: UUID(2)) + RemindersList(id: 1, title: "Personal") + RemindersListPrivate(id: 1, position: 1, remindersListID: 1) + Reminder(id: 1, title: "", remindersListID: 1) + Reminder(id: 2, title: "", remindersListID: 1) + Reminder(id: 3, title: "", remindersListID: 1) + Reminder(id: 4, title: "", remindersListID: 1) + ReminderTag(id: 1, reminderID: 1, tagID: 1) + ReminderTag(id: 2, reminderID: 2, tagID: 1) + ReminderTag(id: 3, reminderID: 3, tagID: 1) + ReminderTag(id: 4, reminderID: 4, tagID: 1) + ReminderTag(id: 5, reminderID: 1, tagID: 2) + ReminderTag(id: 6, reminderID: 2, tagID: 2) + ReminderTag(id: 7, reminderID: 3, tagID: 2) + ReminderTag(id: 8, reminderID: 4, tagID: 2) } } await syncEngine.processBatch() try await userDatabase.userWrite { db in - try RemindersList.find(UUID(1)).delete().execute(db) + try RemindersList.find(RemindersList.ID(1)).delete().execute(db) } await syncEngine.processBatch() diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 014ea5a7..552732e0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -16,14 +16,14 @@ extension BaseCloudKitTests { @Test func saveExtraFieldsToSyncMetadata() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) } } await syncEngine.processBatch() let reminderRecord = try syncEngine.private.database - .record(for: Reminder.recordID(for: UUID(1))) + .record(for: Reminder.recordID(for: 1)) reminderRecord.setValue("Hello world! 🌎🌎🌎", forKey: "newField", at: now) await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) @@ -66,7 +66,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.isCompleted.toggle() }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.isCompleted.toggle() }.execute(db) } await syncEngine.processBatch() @@ -110,9 +110,9 @@ extension BaseCloudKitTests { @Test func remoteChangeParentRelationship() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - RemindersList(id: UUID(2), title: "Business") - Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) } } await syncEngine.processBatch() @@ -121,10 +121,10 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { let reminderRecord = try syncEngine.private.database - .record(for: Reminder.recordID(for: UUID(1))) + .record(for: Reminder.recordID(for: 1)) reminderRecord.setValue(UUID(2).uuidString.lowercased(), forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: UUID(2)), + recordID: RemindersList.recordID(for: 2), action: .none ) @@ -132,7 +132,7 @@ extension BaseCloudKitTests { } assertInlineSnapshot( - of: syncEngine.private.database.storage[Reminder.recordID(for: UUID(1))], + of: syncEngine.private.database.storage[Reminder.recordID(for: 1)], as: .customDump ) { """ @@ -152,22 +152,22 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(UUID(1), table: Reminder.self).fetchOne(db) + try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: UUID(2))) - let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) - #expect(reminder == Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(2))) + #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) + let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) + #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 2)) } }() try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.isCompleted.toggle() }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.isCompleted.toggle() }.execute(db) } await syncEngine.processBatch() assertInlineSnapshot( - of: syncEngine.private.database.storage[Reminder.recordID(for: UUID(1))], + of: syncEngine.private.database.storage[Reminder.recordID(for: 1)], as: .customDump ) { """ @@ -187,16 +187,16 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(UUID(1), table: Reminder.self).fetchOne(db) + try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: UUID(2))) - let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) + #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) + let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) #expect( reminder == Reminder( - id: UUID(1), + id: 1, isCompleted: true, title: "Get milk", - remindersListID: UUID(2) + remindersListID: 2 ) ) } @@ -206,7 +206,7 @@ extension BaseCloudKitTests { @Test func receiveNewRecordFromCloudKit() async throws { let remindersListRecord = CKRecord.init( recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: UUID(1)) + recordID: RemindersList.recordID(for: 1) ) remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) @@ -240,11 +240,11 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(UUID(1), table: RemindersList.self).fetchOne(db) + try SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) ) - #expect(metadata.recordName == RemindersList.recordName(for: UUID(1))) - let remindersList = try #require(try RemindersList.find(UUID(1)).fetchOne(db)) - #expect(remindersList == RemindersList(id: UUID(1), title: "Personal")) + #expect(metadata.recordName == RemindersList.recordName(for: 1)) + let remindersList = try #require(try RemindersList.find(RemindersList.ID(1)).fetchOne(db)) + #expect(remindersList == RemindersList(id: 1, title: "Personal")) } }() @@ -252,7 +252,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in - try RemindersList.find(UUID(1)).update { $0.title = "My stuff" }.execute(db) + try RemindersList.find(RemindersList.ID(1)).update { $0.title = "My stuff" }.execute(db) } await syncEngine.processBatch() @@ -284,8 +284,8 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in - let remindersList = try #require(try RemindersList.find(UUID(1)).fetchOne(db)) - #expect(remindersList == RemindersList(id: UUID(1), title: "My stuff")) + let remindersList = try #require(try RemindersList.find(RemindersList.ID(1)).fetchOne(db)) + #expect(remindersList == RemindersList(id: 1, title: "My stuff")) } }() } @@ -293,20 +293,20 @@ extension BaseCloudKitTests { @Test func receiveNewRecordFromCloudKit_ChildBeforeParent() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: UUID(1)) + recordID: RemindersList.recordID(for: 1) ) remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) let reminderRecord = CKRecord( recordType: Reminder.tableName, - recordID: Reminder.recordID(for: UUID(1)) + recordID: Reminder.recordID(for: 1) ) reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) reminderRecord.setValue("Get milk", forKey: "title", at: now) reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: UUID(1)), + recordID: RemindersList.recordID(for: 1), action: .none ) @@ -351,22 +351,22 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in let reminderMetadata = try #require( - try SyncMetadata.find(UUID(1), table: Reminder.self).fetchOne(db) + try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) - #expect(reminderMetadata.recordName == Reminder.recordName(for: UUID(1))) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(1))) + #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) let remindersListMetadata = try #require( - try SyncMetadata.find(UUID(1), table: RemindersList.self).fetchOne(db) + try SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: UUID(1))) + #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) #expect(remindersListMetadata.parentRecordName == nil) - let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) - #expect(reminder == Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1))) + let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) + #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) - let remindersList = try #require(try RemindersList.find(UUID(1)).fetchOne(db)) - #expect(remindersList == RemindersList(id: UUID(1), title: "Personal")) + let remindersList = try #require(try RemindersList.find(RemindersList.ID(1)).fetchOne(db)) + #expect(remindersList == RemindersList(id: 1, title: "Personal")) } }() @@ -374,7 +374,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.title = "Buy milk" }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.title = "Buy milk" }.execute(db) } await syncEngine.processBatch() @@ -416,8 +416,8 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in - let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) - #expect(reminder == Reminder.init(id: UUID(1), title: "Buy milk", remindersListID: UUID(1))) + let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) + #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 1)) } }() } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index d1df23b8..42c8efe4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -14,9 +14,9 @@ extension BaseCloudKitTests { @Test func deleteCascade() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(2), title: "Walk", remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Groceries", remindersListID: 1) + Reminder(id: 2, title: "Walk", remindersListID: 1) } } @@ -69,7 +69,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try RemindersList.find(UUID(1)).delete().execute(db) + try RemindersList.find(RemindersList.ID(1)).delete().execute(db) } } try await userDatabase.userRead { db in @@ -179,9 +179,9 @@ extension BaseCloudKitTests { @Test func updateCascade() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Walk", remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + Reminder(id: 2, title: "Groceries", remindersListID: 1) + Reminder(id: 3, title: "Walk", remindersListID: 1) } } @@ -234,16 +234,16 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try RemindersList.find(UUID(1)).update { $0.id = UUID(9) }.execute(db) + try RemindersList.find(RemindersList.ID(1)).update { $0.id = 9 }.execute(db) } } try await userDatabase.userRead { db in try expectNoDifference( Reminder.all.fetchAll(db), [ - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(9)), - Reminder(id: UUID(3), title: "Walk", remindersListID: UUID(9)), - Reminder(id: UUID(4), title: "Haircut", remindersListID: UUID(9)), + Reminder(id: 2, title: "Groceries", remindersListID: 9), + Reminder(id: 3, title: "Walk", remindersListID: 9), + Reminder(id: 4, title: "Haircut", remindersListID: 9), ] ) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 1606386f..30c01241 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -15,8 +15,8 @@ extension BaseCloudKitTests { @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "") - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) } } await syncEngine.processBatch() @@ -62,7 +62,7 @@ extension BaseCloudKitTests { """ } - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(60) record.setValue("Buy milk", forKey: "title", at: userModificationDate) let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() @@ -71,7 +71,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(30) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.isCompleted = true }.execute(db) } } await syncEngine.processBatch() @@ -167,8 +167,8 @@ extension BaseCloudKitTests { @Test func serverRecordUpdatedBeforeClientRecord() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "") - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) } } await syncEngine.processBatch() @@ -214,7 +214,7 @@ extension BaseCloudKitTests { """ } - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() @@ -223,7 +223,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.isCompleted = true }.execute(db) } } await syncEngine.processBatch() @@ -319,13 +319,13 @@ extension BaseCloudKitTests { @Test func serverAndClientEditDifferentFields() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "") - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) } } await syncEngine.processBatch() - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() @@ -334,7 +334,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.isCompleted = true }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.isCompleted = true }.execute(db) } } await modificationCallback() @@ -386,13 +386,13 @@ extension BaseCloudKitTests { @Test func serverRecordEditedAfterClientButProcessedBeforeClient() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "") - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) } } await syncEngine.processBatch() - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(60) record.setValue("Buy milk", forKey: "title", at: userModificationDate) let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() @@ -401,7 +401,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(30) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.title = "Get milk" }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.title = "Get milk" }.execute(db) } } await modificationCallback() @@ -409,8 +409,8 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try #expect( - Reminder.find(UUID(1)).fetchOne(db) - == Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + Reminder.find(Reminder.ID(1)).fetchOne(db) + == Reminder(id: 1, title: "Get milk", remindersListID: 1) ) } @@ -504,13 +504,13 @@ extension BaseCloudKitTests { @Test func serverRecordEditedAndProcessedBeforeClient() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "") - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) } } await syncEngine.processBatch() - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() @@ -519,7 +519,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.title = "Get milk" }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.title = "Get milk" }.execute(db) } } await modificationCallback() @@ -571,13 +571,13 @@ extension BaseCloudKitTests { @Test func serverRecordEditedBeforeClientButProcessedAfterClient() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "") - Reminder(id: UUID(1), title: "", remindersListID: UUID(1)) + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) } } await syncEngine.processBatch() - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: UUID(1))) + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) let modificationCallback = { syncEngine.modifyRecords(scope: .private, saving: [record]) }() @@ -586,7 +586,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.title = "Get milk" }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.title = "Get milk" }.execute(db) } } await syncEngine.processBatch() @@ -640,14 +640,14 @@ extension BaseCloudKitTests { @Test func mergeWithNullableFields() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, remindersListID: 1) } } await syncEngine.processBatch() let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: UUID(1)) + for: Reminder.recordID(for: 1) ) reminderRecord.setValue( now.addingTimeInterval(30), @@ -662,7 +662,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(2) } operation: { try userDatabase.userWrite { db in - try Reminder.find(UUID(1)).update { $0.priority = 3 }.execute(db) + try Reminder.find(Reminder.ID(1)).update { $0.priority = 3 }.execute(db) } } @@ -717,14 +717,14 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in - let reminder = try #require(try Reminder.find(UUID(1)).fetchOne(db)) + let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) #expect( reminder == Reminder( - id: UUID(1), + id: 1, dueDate: Date(timeIntervalSince1970: 30), priority: 3, - remindersListID: UUID(1) + remindersListID: 1 ) ) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 90dc361f..bd6ebe88 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -14,9 +14,9 @@ extension BaseCloudKitTests { @Test func parentRecordName() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - RemindersList(id: UUID(2), title: "Work") - Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Work") + Reminder(id: 1, title: "Groceries", remindersListID: 1) } } @@ -66,26 +66,26 @@ extension BaseCloudKitTests { try await userDatabase.userRead { db in let reminderMetadata = try #require( try SyncMetadata - .where { $0.recordName.eq(Reminder.recordName(for: UUID(1))) } + .where { $0.recordName.eq(Reminder.recordName(for: 1)) } .fetchOne(db) ) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(1))) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) } - try await withDependencies { + try withDependencies { $0.date.now.addTimeInterval(60) } operation: { _ = try { try userDatabase.userWrite { db in - try Reminder.find(UUID(1)) - .update { $0.remindersListID = UUID(2) } + try Reminder.find(Reminder.ID(1)) + .update { $0.remindersListID = 2 } .execute(db) let reminderMetadata = try #require( try SyncMetadata - .where { $0.recordName.eq(Reminder.recordName(for: UUID(1))) } + .where { $0.recordName.eq(Reminder.recordName(for: 1)) } .fetchOne(db) ) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: UUID(2))) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 2)) } }() } @@ -137,10 +137,10 @@ extension BaseCloudKitTests { @Test func noParentRecordForRecordsWithMultipleForeignKeys() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) - Tag(id: UUID(1), title: "weekend") - ReminderTag(id: UUID(1), reminderID: UUID(1), tagID: UUID(1)) + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Groceries", remindersListID: 1) + Tag(id: 1, title: "weekend") + ReminderTag(id: 1, reminderID: 1, tagID: 1) } } @@ -208,10 +208,10 @@ extension BaseCloudKitTests { @Test func recordType() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(4), title: "Groceries", remindersListID: UUID(1)) + 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) } } @@ -224,9 +224,9 @@ extension BaseCloudKitTests { } #expect( reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: UUID(2)), - Reminder.recordName(for: UUID(3)), - Reminder.recordName(for: UUID(4)), + Reminder.recordName(for: 2), + Reminder.recordName(for: 3), + Reminder.recordName(for: 4), ] ) } @@ -234,10 +234,10 @@ extension BaseCloudKitTests { @Test func parentRecordType() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(4), title: "Groceries", remindersListID: UUID(1)) + 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) } } @@ -250,9 +250,9 @@ extension BaseCloudKitTests { .fetchAll(db) #expect( reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: UUID(2)), - Reminder.recordName(for: UUID(3)), - Reminder.recordName(for: UUID(4)), + Reminder.recordName(for: 2), + Reminder.recordName(for: 3), + Reminder.recordName(for: 4), ] ) } @@ -261,10 +261,10 @@ extension BaseCloudKitTests { @Test func parentRecordPrimaryKey() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(2), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(3), title: "Groceries", remindersListID: UUID(1)) - Reminder(id: UUID(4), title: "Groceries", remindersListID: UUID(1)) + 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) } } @@ -277,9 +277,9 @@ extension BaseCloudKitTests { .fetchAll(db) #expect( reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: UUID(2)), - Reminder.recordName(for: UUID(3)), - Reminder.recordName(for: UUID(4)), + Reminder.recordName(for: 2), + Reminder.recordName(for: 3), + Reminder.recordName(for: 4), ] ) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index a26dfe90..1ef0c97c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -14,8 +14,8 @@ extension BaseCloudKitTests { setUpUserDatabase: { userDatabase in try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), title: "Write blog post", remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Write blog post", remindersListID: 1) } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index dffbcd40..4e7ebf88 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -11,7 +11,7 @@ extension BaseCloudKitTests { final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { @Test func noMetadataForRecord() async throws { syncEngine.private.state.add( - pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: UUID(1)))] + pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: 1))] ) await syncEngine.processBatch() @@ -92,7 +92,7 @@ extension BaseCloudKitTests { @Test func saveRecord() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: 1, title: "Personal") } } @@ -126,8 +126,8 @@ extension BaseCloudKitTests { func saveRecordWithParent() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) } } @@ -170,8 +170,8 @@ extension BaseCloudKitTests { @Test func savePrivateRecord() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - RemindersListPrivate(id: UUID(1), position: 42, remindersListID: UUID(1)) + RemindersList(id: 1, title: "Personal") + RemindersListPrivate(id: 1, position: 42, remindersListID: 1) } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index 4b85f5c3..bae2991c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -17,9 +17,9 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addColumnToRemindersAndRemindersLists() async throws { - let personalList = RemindersList(id: UUID(1), title: "Personal") - let businessList = RemindersList(id: UUID(2), title: "Business") - let reminder = Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + let personalList = RemindersList(id: 1, title: "Personal") + let businessList = RemindersList(id: 2, title: "Business") + let reminder = Reminder(id: 1, title: "Get milk", remindersListID: 1) try await userDatabase.userWrite { db in try db.seed { personalList @@ -34,17 +34,17 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { let personalListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: UUID(1)) + for: RemindersList.recordID(for: 1) ) personalListRecord.setValue(1, forKey: "position", at: now) let businessListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: UUID(2)) + for: RemindersList.recordID(for: 2) ) businessListRecord.setValue(2, forKey: "position", at: now) let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: UUID(1)) + for: Reminder.recordID(for: 1) ) reminderRecord.setValue(3, forKey: "position", at: now) @@ -103,7 +103,7 @@ extension BaseCloudKitTests { id: UUID(1), title: "Get milk", position: 3, - remindersListID: UUID(1) + remindersListID: 1 ) ] ) @@ -112,7 +112,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addAssetToRemindersList() async throws { - let personalList = RemindersList(id: UUID(1), title: "Personal") + let personalList = RemindersList(id: 1, title: "Personal") try await userDatabase.userWrite { db in try db.seed { personalList @@ -125,7 +125,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { let personalListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: UUID(1)) + for: RemindersList.recordID(for: 1) ) personalListRecord.setValue(Array("image".utf8), forKey: "image", at: now) @@ -173,9 +173,9 @@ extension BaseCloudKitTests { @Test func addAssetToRemindersList_Redownload() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") - RemindersList(id: UUID(2), title: "Business") - RemindersList(id: UUID(3), title: "Secret") + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + RemindersList(id: 3, title: "Secret") } } @@ -185,15 +185,15 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { let personalListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: UUID(1)) + for: RemindersList.recordID(for: 1) ) personalListRecord.setValue(Array("personal-image".utf8), forKey: "image", at: now) let businessListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: UUID(2)) + for: RemindersList.recordID(for: 2) ) businessListRecord.setValue(Array("business-image".utf8), forKey: "image", at: now) let secretListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: UUID(3)) + for: RemindersList.recordID(for: 3) ) secretListRecord.setValue(Array("secret-image".utf8), forKey: "image", at: now) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index bf356020..5f924ec0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -14,10 +14,10 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareNonRootRecord() async throws { - let reminder = Reminder(id: UUID(1), title: "Groceries", remindersListID: UUID(1)) + let reminder = Reminder(id: 1, title: "Groceries", remindersListID: 1) try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: UUID(1), title: "Personal") + RemindersList(id: 1, title: "Personal") reminder } } @@ -43,7 +43,7 @@ extension BaseCloudKitTests { @Test func sharePrivateTable() async throws { await #expect(throws: SyncEngine.PrivateRootRecord.self) { _ = try await self.syncEngine.share( - record: RemindersListPrivate(id: UUID(1), remindersListID: UUID(1)), + record: RemindersListPrivate(id: 1, remindersListID: 1), configure: { _ in } ) } @@ -53,7 +53,7 @@ extension BaseCloudKitTests { @Test func shareRecordBeforeSync() async throws { await #expect(throws: SyncEngine.NoCKRecordFound.self) { _ = try await self.syncEngine.share( - record: RemindersList(id: UUID(1)), + record: RemindersList(id: 1), configure: { _ in } ) } @@ -68,7 +68,7 @@ extension BaseCloudKitTests { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListRecord.setValue(false, forKey: "isCompleted", at: now) @@ -81,7 +81,7 @@ extension BaseCloudKitTests { } operation: { try await userDatabase.userWrite { db in try db.seed { - Reminder(id: UUID(1), title: "Get milk", remindersListID: UUID(1)) + Reminder(id: 1, title: "Get milk", remindersListID: 1) } } } @@ -203,13 +203,13 @@ extension BaseCloudKitTests { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) let reminderRecord = CKRecord( recordType: Reminder.tableName, - recordID: Reminder.recordID(for: UUID(1), zoneID: externalZoneID) + recordID: Reminder.recordID(for: 1, zoneID: externalZoneID) ) reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) reminderRecord.setValue(false, forKey: "isCompleted", at: now) @@ -222,7 +222,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(UUID(1)).delete().execute(db) + try Reminder.find(Reminder.ID(1)).delete().execute(db) } } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index a28ae6f7..35d573a7 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -5,8 +5,11 @@ import OrderedCollections import SharingGRDBCore import Testing -extension PrimaryKeyedTable { - static func recordID(for id: UUID, zoneID: CKRecordZone.ID? = nil) -> CKRecord.ID { +extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { + static func recordID( + for id: PrimaryKey.QueryOutput, + zoneID: CKRecordZone.ID? = nil + ) -> CKRecord.ID { CKRecord.ID( recordName: self.recordName(for: id), zoneID: zoneID ?? SyncEngine.defaultZone.zoneID diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index cf7c9890..1e6eb81c 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -2,7 +2,7 @@ import Foundation import SharingGRDB @Table struct Reminder: Equatable, Identifiable { - let id: UUID + let id: Identifier var dueDate: Date? var isCompleted = false var priority: Int? @@ -10,25 +10,25 @@ import SharingGRDB var remindersListID: RemindersList.ID } @Table struct RemindersList: Equatable, Identifiable { - let id: UUID + let id: Identifier var title = "" } @Table struct RemindersListAsset: Equatable, Identifiable { - let id: UUID + let id: Identifier var coverImage: Data? var remindersListID: RemindersList.ID } @Table struct RemindersListPrivate: Equatable, Identifiable { - let id: UUID + let id: Identifier var position = 0 var remindersListID: RemindersList.ID } @Table struct Tag: Equatable, Identifiable { - let id: UUID + let id: Identifier var title = "" } @Table struct ReminderTag: Equatable, Identifiable { - let id: UUID + let id: Identifier var reminderID: Reminder.ID var tagID: Tag.ID } @@ -68,6 +68,30 @@ import SharingGRDB var modelBID: ModelB.ID } +struct Identifier: + ExpressibleByIntegerLiteral, + Hashable, + RawRepresentable, + IdentifierStringConvertible, + QueryBindable +{ + let rawValue: UUID + var rawIdentifier: String { rawValue.rawIdentifier } + init(rawValue: UUID) { + self.rawValue = rawValue + } + init?(rawIdentifier: String) { + guard let rawValue = UUID(uuidString: rawIdentifier) else { return nil } + self.init(rawValue: rawValue) + } + init(_ intValue: Int) { + self.init(rawValue: UUID(intValue)) + } + init(integerLiteral value: Int) { + self.init(value) + } +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func database(containerIdentifier: String) throws -> DatabasePool { var configuration = Configuration() From e481edf24c8d1b5cd40d2a0a25c1aa265503ba92 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 15 Jul 2025 15:34:25 -0700 Subject: [PATCH 382/581] wip --- .../CloudKitTests/AssetsTests.swift | 22 ++--- .../CloudKitTests/CloudKitTests.swift | 38 +++---- .../FetchRecordZoneChangesTests.swift | 68 ++++++------- .../CloudKitTests/ForeignKeyTests.swift | 98 +++++++++---------- .../CloudKitTests/MergeConflictTests.swift | 90 ++++++++--------- .../CloudKitTests/MetadataTests.swift | 34 +++---- .../CloudKitTests/NewTableSyncTests.swift | 24 ++--- .../NextRecordZoneChangeBatchTests.swift | 18 ++-- .../CloudKitTests/SchemaChangeTests.swift | 28 +++--- .../CloudKitTests/SharingTests.swift | 36 +++---- .../Internal/CloudKit+CustomDump.swift | 2 +- Tests/SharingGRDBTests/Internal/Schema.swift | 55 ++++------- 12 files changed, 246 insertions(+), 267 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index d7a01edd..891511d8 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -38,8 +38,8 @@ extension BaseCloudKitTests { recordType: "remindersListAssets", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", - remindersListID: "00000000-0000-0000-0000-000000000001", + id: 1, + remindersListID: 1, coverImage: CKAsset( fileURL: URL(file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d), dataString: "image" @@ -50,7 +50,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -73,7 +73,7 @@ extension BaseCloudKitTests { } operation: { try await userDatabase.userWrite { db in try RemindersListAsset - .find(RemindersListAsset.ID(1)) + .find(1) .update { $0.coverImage = Data("new-image".utf8) } .execute(db) } @@ -92,8 +92,8 @@ extension BaseCloudKitTests { recordType: "remindersListAssets", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", - remindersListID: "00000000-0000-0000-0000-000000000001", + id: 1, + remindersListID: 1, coverImage: CKAsset( fileURL: URL(file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf), dataString: "new-image" @@ -104,7 +104,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -130,21 +130,21 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) ) - remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue("1", forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) let remindersListAssetRecord = CKRecord( recordType: RemindersListAsset.tableName, recordID: RemindersListAsset.recordID(for: 1) ) - remindersListAssetRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListAssetRecord.setValue("1", forKey: "id", at: now) remindersListAssetRecord.setValue( Array("image".utf8), forKey: "coverImage", at: now ) remindersListAssetRecord.setValue( - UUID(1).uuidString.lowercased(), + "1", forKey: "remindersListID", at: now ) @@ -161,7 +161,7 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in let remindersListAsset = try #require( - try RemindersListAsset.find(RemindersListAsset.ID(1)).fetchOne(db) + try RemindersListAsset.find(1).fetchOne(db) ) #expect(remindersListAsset.coverImage == Data("image".utf8)) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 07b3d36d..10d53d79 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -468,7 +468,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -514,7 +514,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -593,7 +593,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -611,7 +611,7 @@ extension BaseCloudKitTests { } operation: { try await userDatabase.userWrite { db in try RemindersList - .find(RemindersList.ID(1)) + .find(1) .update { $0.title = "Work" } .execute(db) } @@ -628,7 +628,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Work" ) ] @@ -643,7 +643,7 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try RemindersList - .find(RemindersList.ID(1)) + .find(1) .delete() .execute(db) } @@ -683,7 +683,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -712,7 +712,7 @@ extension BaseCloudKitTests { expectNoDifference( try { try userDatabase.userRead { db in - try RemindersList.find(RemindersList.ID(1)).fetchOne(db) } + try RemindersList.find(1).fetchOne(db) } }(), RemindersList(id: 1, title: "Work") ) @@ -736,7 +736,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Work" ) ] @@ -763,7 +763,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in - try RemindersList.find(RemindersList.ID(1)).update { $0.title = "My stuff" }.execute(db) + try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) } } @@ -781,7 +781,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "My stuff" ) ] @@ -814,7 +814,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -843,7 +843,7 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords(scope: .private, saving: [record]) expectNoDifference( - try { try userDatabase.userRead { db in try RemindersList.find(RemindersList.ID(1)).fetchOne(db) } }(), + try { try userDatabase.userRead { db in try RemindersList.find(1).fetchOne(db) } }(), RemindersList(id: 1, title: "Personal") ) @@ -866,7 +866,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -899,7 +899,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -917,7 +917,7 @@ extension BaseCloudKitTests { #expect( try await userDatabase.userRead { db in - try RemindersList.find(RemindersList.ID(1)).fetchAll(db) + try RemindersList.find(1).fetchAll(db) } == [] ) let metadata = try await userDatabase.userRead { db in @@ -972,7 +972,7 @@ extension BaseCloudKitTests { await syncEngine.processBatch() try await userDatabase.userWrite { db in - try RemindersList.find(RemindersList.ID(1)).delete().execute(db) + try RemindersList.find(1).delete().execute(db) } await syncEngine.processBatch() @@ -987,7 +987,7 @@ extension BaseCloudKitTests { recordType: "tags", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "" ), [1]: CKRecord( @@ -995,7 +995,7 @@ extension BaseCloudKitTests { recordType: "tags", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000002", + id: 2, title: "" ) ] diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 552732e0..5a7ab42a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -43,10 +43,10 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, newField: "Hello world! 🌎🌎🌎", - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Get milk" ), [1]: CKRecord( @@ -54,7 +54,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -66,7 +66,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).update { $0.isCompleted.toggle() }.execute(db) + try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) } await syncEngine.processBatch() @@ -86,10 +86,10 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 1, newField: "Hello world! 🌎🌎🌎", - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Get milk" ), [1]: CKRecord( @@ -97,7 +97,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -122,7 +122,7 @@ extension BaseCloudKitTests { } operation: { let reminderRecord = try syncEngine.private.database .record(for: Reminder.recordID(for: 1)) - reminderRecord.setValue(UUID(2).uuidString.lowercased(), forKey: "remindersListID", at: now) + reminderRecord.setValue("2", forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference( recordID: RemindersList.recordID(for: 2), action: .none @@ -141,9 +141,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000002", + remindersListID: "2", title: "Get milk" ) """ @@ -155,13 +155,13 @@ extension BaseCloudKitTests { try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) - let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) + 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(Reminder.ID(1)).update { $0.isCompleted.toggle() }.execute(db) + try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) } await syncEngine.processBatch() @@ -176,9 +176,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000002", + remindersListID: "2", title: "Get milk" ) """ @@ -190,7 +190,7 @@ extension BaseCloudKitTests { try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) - let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) + let reminder = try #require(try Reminder.find(1).fetchOne(db)) #expect( reminder == Reminder( id: 1, @@ -208,7 +208,7 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) ) - remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue("1", forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) @@ -224,7 +224,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: "1", title: "Personal" ) ] @@ -243,7 +243,7 @@ extension BaseCloudKitTests { try SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) ) #expect(metadata.recordName == RemindersList.recordName(for: 1)) - let remindersList = try #require(try RemindersList.find(RemindersList.ID(1)).fetchOne(db)) + let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) #expect(remindersList == RemindersList(id: 1, title: "Personal")) } }() @@ -252,7 +252,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in - try RemindersList.find(RemindersList.ID(1)).update { $0.title = "My stuff" }.execute(db) + try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) } await syncEngine.processBatch() @@ -269,7 +269,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "My stuff" ) ] @@ -284,7 +284,7 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in - let remindersList = try #require(try RemindersList.find(RemindersList.ID(1)).fetchOne(db)) + let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) #expect(remindersList == RemindersList(id: 1, title: "My stuff")) } }() @@ -295,16 +295,16 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) ) - remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue("1", forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) let reminderRecord = CKRecord( recordType: Reminder.tableName, recordID: Reminder.recordID(for: 1) ) - reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + reminderRecord.setValue("1", forKey: "id", at: now) reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "remindersListID", at: now) + reminderRecord.setValue("1", forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference( recordID: RemindersList.recordID(for: 1), action: .none @@ -324,8 +324,8 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", - remindersListID: "00000000-0000-0000-0000-000000000001", + id: "1", + remindersListID: "1", title: "Get milk" ), [1]: CKRecord( @@ -333,7 +333,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: "1", title: "Personal" ) ] @@ -362,10 +362,10 @@ extension BaseCloudKitTests { #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) #expect(remindersListMetadata.parentRecordName == nil) - let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) + 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(RemindersList.ID(1)).fetchOne(db)) + let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) #expect(remindersList == RemindersList(id: 1, title: "Personal")) } }() @@ -374,7 +374,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).update { $0.title = "Buy milk" }.execute(db) + try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) } await syncEngine.processBatch() @@ -391,9 +391,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Buy milk" ), [1]: CKRecord( @@ -401,7 +401,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: "1", title: "Personal" ) ] @@ -416,7 +416,7 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in - let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) + let reminder = try #require(try Reminder.find(1).fetchOne(db)) #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 1)) } }() diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 42c8efe4..5f16e2c2 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -32,9 +32,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Groceries" ), [1]: CKRecord( @@ -42,9 +42,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000002", + id: 2, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Walk" ), [2]: CKRecord( @@ -52,7 +52,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -69,7 +69,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try RemindersList.find(RemindersList.ID(1)).delete().execute(db) + try RemindersList.find(1).delete().execute(db) } } try await userDatabase.userRead { db in @@ -97,8 +97,8 @@ extension BaseCloudKitTests { @Test func deleteSetNull() async throws { try await userDatabase.userWrite { db in try db.seed { - Parent(id: UUID(1)) - ChildWithOnDeleteSetNull(id: UUID(1), parentID: UUID(1)) + Parent(id: 1) + ChildWithOnDeleteSetNull(id: 1, parentID: 1) } } @@ -114,15 +114,15 @@ extension BaseCloudKitTests { recordType: "childWithOnDeleteSetNulls", parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001" + id: 1, + parentID: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001" + id: 1 ) ] ), @@ -138,14 +138,14 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Parent.find(UUID(1)).delete().execute(db) + try Parent.find(1).delete().execute(db) } } try await userDatabase.userRead { db in try expectNoDifference( ChildWithOnDeleteSetNull.all.fetchAll(db), [ - ChildWithOnDeleteSetNull(id: UUID(1), parentID: nil) + ChildWithOnDeleteSetNull(id: 1, parentID: nil) ] ) } @@ -162,7 +162,7 @@ extension BaseCloudKitTests { recordType: "childWithOnDeleteSetNulls", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001" + id: 1 ) ] ), @@ -197,9 +197,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000002", + id: 2, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Groceries" ), [1]: CKRecord( @@ -207,9 +207,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000003", + id: 3, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Walk" ), [2]: CKRecord( @@ -217,7 +217,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -234,7 +234,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try RemindersList.find(RemindersList.ID(1)).update { $0.id = 9 }.execute(db) + try RemindersList.find(1).update { $0.id = 9 }.execute(db) } } try await userDatabase.userRead { db in @@ -260,9 +260,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000002", + id: 2, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000009", + remindersListID: 9, title: "Groceries" ), [1]: CKRecord( @@ -270,9 +270,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000003", + id: 3, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000009", + remindersListID: 9, title: "Walk" ), [2]: CKRecord( @@ -280,7 +280,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ), [3]: CKRecord( @@ -288,7 +288,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000009", + id: 9, title: "Personal" ) ] @@ -306,8 +306,8 @@ extension BaseCloudKitTests { @Test func deleteRestrict() async throws { try await userDatabase.userWrite { db in try db.seed { - Parent(id: UUID(1)) - ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) + Parent(id: 1) + ChildWithOnDeleteRestrict(id: 1, parentID: 1) } } @@ -323,15 +323,15 @@ extension BaseCloudKitTests { recordType: "childWithOnDeleteRestricts", parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001" + id: 1, + parentID: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001" + id: 1 ) ] ), @@ -347,7 +347,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { try self.userDatabase.userWrite { db in - try Parent.find(UUID(1)).delete().execute(db) + try Parent.find(1).delete().execute(db) } } } @@ -356,7 +356,7 @@ extension BaseCloudKitTests { try expectNoDifference( ChildWithOnDeleteRestrict.all.fetchAll(db), [ - ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) + ChildWithOnDeleteRestrict(id: 1, parentID: 1) ] ) } @@ -373,15 +373,15 @@ extension BaseCloudKitTests { recordType: "childWithOnDeleteRestricts", parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001" + id: 1, + parentID: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001" + id: 1 ) ] ), @@ -398,8 +398,8 @@ extension BaseCloudKitTests { @Test func updateRestrict() async throws { try await userDatabase.userWrite { db in try db.seed { - Parent(id: UUID(1)) - ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) + Parent(id: 1) + ChildWithOnDeleteRestrict(id: 1, parentID: 1) } } @@ -415,15 +415,15 @@ extension BaseCloudKitTests { recordType: "childWithOnDeleteRestricts", parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001" + id: 1, + parentID: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001" + id: 1 ) ] ), @@ -439,7 +439,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { try self.userDatabase.userWrite { db in - try Parent.find(UUID(1)).update { $0.id = UUID(2) }.execute(db) + try Parent.find(1).update { $0.id = 2 }.execute(db) } } } @@ -448,7 +448,7 @@ extension BaseCloudKitTests { try expectNoDifference( ChildWithOnDeleteRestrict.all.fetchAll(db), [ - ChildWithOnDeleteRestrict(id: UUID(1), parentID: UUID(1)) + ChildWithOnDeleteRestrict(id: 1, parentID: 1) ] ) } @@ -465,15 +465,15 @@ extension BaseCloudKitTests { recordType: "childWithOnDeleteRestricts", parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", - parentID: "00000000-0000-0000-0000-000000000001" + id: 1, + parentID: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001" + id: 1 ) ] ), @@ -490,12 +490,12 @@ extension BaseCloudKitTests { @Test func nonSyncTable() async throws { try await userDatabase.userWrite { db in try db.seed { - LocalUser(id: UUID(1), name: "Blob", parentID: nil) - LocalUser(id: UUID(2), name: "Blob Jr", parentID: UUID(1)) + LocalUser(id: 1, name: "Blob", parentID: nil) + LocalUser(id: 2, name: "Blob Jr", parentID: 1) } } try await self.userDatabase.userWrite { db in - try LocalUser.find(UUID(1)).delete().execute(db) + try LocalUser.find(1).delete().execute(db) } try await userDatabase.userRead { db in try expectNoDifference( diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 30c01241..cb9fd0c7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -31,11 +31,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "", title🗓️: 0, @@ -46,7 +46,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -71,7 +71,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(30) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).update { $0.isCompleted = true }.execute(db) + try Reminder.find(1).update { $0.isCompleted = true }.execute(db) } } await syncEngine.processBatch() @@ -87,11 +87,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", title🗓️: 60, @@ -102,7 +102,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -132,11 +132,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 1, isCompleted🗓️: 30, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", title🗓️: 60, @@ -147,7 +147,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -183,11 +183,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "", title🗓️: 0, @@ -198,7 +198,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -223,7 +223,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).update { $0.isCompleted = true }.execute(db) + try Reminder.find(1).update { $0.isCompleted = true }.execute(db) } } await syncEngine.processBatch() @@ -239,11 +239,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", title🗓️: 30, @@ -254,7 +254,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -284,11 +284,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 1, isCompleted🗓️: 60, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", title🗓️: 30, @@ -299,7 +299,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -334,7 +334,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).update { $0.isCompleted = true }.execute(db) + try Reminder.find(1).update { $0.isCompleted = true }.execute(db) } } await modificationCallback() @@ -351,11 +351,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 1, isCompleted🗓️: 60, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", title🗓️: 30, @@ -366,7 +366,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -401,7 +401,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(30) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).update { $0.title = "Get milk" }.execute(db) + try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) } } await modificationCallback() @@ -409,7 +409,7 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try #expect( - Reminder.find(Reminder.ID(1)).fetchOne(db) + Reminder.find(1).fetchOne(db) == Reminder(id: 1, title: "Get milk", remindersListID: 1) ) } @@ -425,11 +425,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", title🗓️: 60, @@ -440,7 +440,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -469,11 +469,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "Buy milk", title🗓️: 60, @@ -484,7 +484,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -519,7 +519,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).update { $0.title = "Get milk" }.execute(db) + try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) } } await modificationCallback() @@ -536,11 +536,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "Get milk", title🗓️: 60, @@ -551,7 +551,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -586,7 +586,7 @@ extension BaseCloudKitTests { $0.date.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).update { $0.title = "Get milk" }.execute(db) + try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) } } await syncEngine.processBatch() @@ -604,11 +604,11 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "Get milk", title🗓️: 60, @@ -619,7 +619,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "", title🗓️: 0, @@ -662,7 +662,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(2) } operation: { try userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).update { $0.priority = 3 }.execute(db) + try Reminder.find(1).update { $0.priority = 3 }.execute(db) } } @@ -682,13 +682,13 @@ extension BaseCloudKitTests { share: nil, dueDate: Date(1970-01-01T00:00:30.000Z), dueDate🗓️: 1, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, isCompleted: 0, isCompleted🗓️: 0, priority: 3, priority🗓️: 2, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, remindersListID🗓️: 0, title: "", title🗓️: 0, @@ -699,7 +699,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, id🗓️: 0, title: "Personal", title🗓️: 0, @@ -717,7 +717,7 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in - let reminder = try #require(try Reminder.find(Reminder.ID(1)).fetchOne(db)) + let reminder = try #require(try Reminder.find(1).fetchOne(db)) #expect( reminder == Reminder( diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index bd6ebe88..ae6f1e59 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -32,9 +32,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Groceries" ), [1]: CKRecord( @@ -42,7 +42,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ), [2]: CKRecord( @@ -50,7 +50,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000002", + id: 2, title: "Work" ) ] @@ -77,7 +77,7 @@ extension BaseCloudKitTests { } operation: { _ = try { try userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)) + try Reminder.find(1) .update { $0.remindersListID = 2 } .execute(db) let reminderMetadata = try #require( @@ -102,9 +102,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000002", + remindersListID: 2, title: "Groceries" ), [1]: CKRecord( @@ -112,7 +112,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ), [2]: CKRecord( @@ -120,7 +120,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000002", + id: 2, title: "Work" ) ] @@ -156,18 +156,18 @@ extension BaseCloudKitTests { recordType: "reminderTags", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", - reminderID: "00000000-0000-0000-0000-000000000001", - tagID: "00000000-0000-0000-0000-000000000001" + id: 1, + reminderID: 1, + tagID: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Groceries" ), [2]: CKRecord( @@ -175,7 +175,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ), [3]: CKRecord( @@ -183,7 +183,7 @@ extension BaseCloudKitTests { recordType: "tags", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "weekend" ) ] @@ -273,7 +273,7 @@ extension BaseCloudKitTests { try await userDatabase.userRead { db in let reminderMetadata = try SyncMetadata - .where { $0.parentRecordPrimaryKey.eq(UUID(1).uuidString.lowercased()) } + .where { $0.parentRecordPrimaryKey.eq("1") } .fetchAll(db) #expect( reminderMetadata.map(\.recordName) == [ diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 1ef0c97c..914b8f97 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -36,9 +36,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Write blog post" ), [1]: CKRecord( @@ -46,7 +46,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -66,12 +66,12 @@ extension BaseCloudKitTests { """ [ [0]: SyncMetadata( - recordPrimaryKey: "00000000-0000-0000-0000-000000000001", + recordPrimaryKey: "1", recordType: "reminders", - recordName: "00000000-0000-0000-0000-000000000001:reminders", - parentRecordPrimaryKey: "00000000-0000-0000-0000-000000000001", + recordName: "1:reminders", + parentRecordPrimaryKey: "1", parentRecordType: "remindersLists", - parentRecordName: "00000000-0000-0000-0000-000000000001:remindersLists", + parentRecordName: "1:remindersLists", lastKnownServerRecord: CKRecord( recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "reminders", @@ -83,9 +83,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Write blog post" ), share: nil, @@ -93,9 +93,9 @@ extension BaseCloudKitTests { userModificationDate: Date(1970-01-01T00:00:00.000Z) ), [1]: SyncMetadata( - recordPrimaryKey: "00000000-0000-0000-0000-000000000001", + recordPrimaryKey: "1", recordType: "remindersLists", - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + recordName: "1:remindersLists", parentRecordPrimaryKey: nil, parentRecordType: nil, parentRecordName: nil, @@ -110,7 +110,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ), share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 4e7ebf88..3671a35d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -35,7 +35,7 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try SyncMetadata.insert { SyncMetadata( - recordPrimaryKey: UUID(1).uuidString.lowercased(), + recordPrimaryKey: "1", recordType: UnrecognizedTable.tableName, userModificationDate: .distantPast ) @@ -64,7 +64,7 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try SyncMetadata.insert { SyncMetadata( - recordPrimaryKey: UUID(1).uuidString.lowercased(), + recordPrimaryKey: "1", recordType: RemindersList.tableName, userModificationDate: .distantPast ) @@ -108,7 +108,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -143,9 +143,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Get milk" ), [1]: CKRecord( @@ -153,7 +153,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] @@ -187,16 +187,16 @@ extension BaseCloudKitTests { recordType: "remindersListPrivates", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, position: 42, - remindersListID: "00000000-0000-0000-0000-000000000001" + remindersListID: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, title: "Personal" ) ] diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index bae2991c..c728a917 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -92,15 +92,15 @@ extension BaseCloudKitTests { expectNoDifference( remindersLists, [ - RemindersListWithPosition(id: UUID(1), title: "Personal", position: 1), - RemindersListWithPosition(id: UUID(2), title: "Business", position: 2), + RemindersListWithPosition(id: 1, title: "Personal", position: 1), + RemindersListWithPosition(id: 2, title: "Business", position: 2), ] ) expectNoDifference( reminders, [ ReminderWithPosition( - id: UUID(1), + id: 1, title: "Get milk", position: 3, remindersListID: 1 @@ -163,7 +163,7 @@ extension BaseCloudKitTests { expectNoDifference( remindersLists, [ - RemindersListWithData(id: UUID(1), image: Data("image".utf8), title: "Personal") + RemindersListWithData(id: 1, image: Data("image".utf8), title: "Personal") ] ) } @@ -233,9 +233,9 @@ extension BaseCloudKitTests { expectNoDifference( remindersLists, [ - RemindersListWithData(id: UUID(1), image: Data("personal-image".utf8), title: "Personal"), - RemindersListWithData(id: UUID(2), image: Data("business-image".utf8), title: "Business"), - RemindersListWithData(id: UUID(3), image: Data("secret-image".utf8), title: "Secret"), + RemindersListWithData(id: 1, image: Data("personal-image".utf8), title: "Personal"), + RemindersListWithData(id: 2, image: Data("business-image".utf8), title: "Business"), + RemindersListWithData(id: 3, image: Data("secret-image".utf8), title: "Secret"), ] ) } @@ -250,9 +250,9 @@ extension BaseCloudKitTests { } operation: { let imageRecord = CKRecord( recordType: "images", - recordID: Image.recordID(for: UUID(1)) + recordID: Image.recordID(for: 1) ) - imageRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + imageRecord.setValue("1", forKey: "id", at: now) imageRecord.setValue("A good image", forKey: "caption", at: now) imageRecord.setValue(Data("image".utf8), forKey: "image", at: now) @@ -293,7 +293,7 @@ extension BaseCloudKitTests { expectNoDifference( images, [ - Image(id: UUID(1), image: Data("image".utf8), caption: "A good image") + Image(id: 1, image: Data("image".utf8), caption: "A good image") ] ) } @@ -303,14 +303,14 @@ extension BaseCloudKitTests { @Table("remindersLists") private struct RemindersListWithPosition: Equatable, Identifiable { - let id: UUID + let id: Int var title = "" var position = 0 } @Table("reminders") private struct ReminderWithPosition: Equatable, Identifiable { - let id: UUID + let id: Int var title = "" var position = 0 var remindersListID: RemindersList.ID @@ -318,14 +318,14 @@ private struct ReminderWithPosition: Equatable, Identifiable { @Table("remindersLists") private struct RemindersListWithData: Equatable, Identifiable { - let id: UUID + let id: Int var image: Data var title = "" } @Table private struct Image: Equatable, Identifiable { - let id: UUID + let id: Int var image: Data var caption = "" } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 5f924ec0..698eca40 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -70,7 +70,7 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) - remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue("1", forKey: "id", at: now) remindersListRecord.setValue(false, forKey: "isCompleted", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) @@ -102,9 +102,9 @@ extension BaseCloudKitTests { recordType: "reminders", parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isCompleted: 0, - remindersListID: "00000000-0000-0000-0000-000000000001", + remindersListID: 1, title: "Get milk" ), [1]: CKRecord( @@ -112,7 +112,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: "1", isCompleted: 0, title: "Personal" ) @@ -132,9 +132,9 @@ extension BaseCloudKitTests { let modelARecord = CKRecord( recordType: ModelA.tableName, - recordID: ModelA.recordID(for: UUID(1), zoneID: externalZoneID) + recordID: ModelA.recordID(for: 1, zoneID: externalZoneID) ) - modelARecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + modelARecord.setValue("1", forKey: "id", at: now) modelARecord.setValue(0, forKey: "count", at: now) await syncEngine.modifyRecords(scope: .shared, saving: [modelARecord]) @@ -144,8 +144,8 @@ extension BaseCloudKitTests { } operation: { try await userDatabase.userWrite { db in try db.seed { - ModelB(id: UUID(1), modelAID: UUID(1)) - ModelC(id: UUID(1), modelBID: UUID(1)) + ModelB(id: 1, modelAID: 1) + ModelC(id: 1, modelBID: 1) } } } @@ -167,24 +167,24 @@ extension BaseCloudKitTests { parent: nil, share: nil, count: 0, - id: "00000000-0000-0000-0000-000000000001" + id: "1" ), [1]: CKRecord( recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), recordType: "modelBs", parent: CKReference(recordID: CKRecord.ID(1:modelAs/external.zone/external.owner)), share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: 1, isOn: 0, - modelAID: "00000000-0000-0000-0000-000000000001" + modelAID: 1 ), [2]: CKRecord( recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), recordType: "modelCs", parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), share: nil, - id: "00000000-0000-0000-0000-000000000001", - modelBID: "00000000-0000-0000-0000-000000000001", + id: 1, + modelBID: 1, title: "" ) ] @@ -205,16 +205,16 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) - remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue("1", forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) let reminderRecord = CKRecord( recordType: Reminder.tableName, recordID: Reminder.recordID(for: 1, zoneID: externalZoneID) ) - reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + reminderRecord.setValue("1", forKey: "id", at: now) reminderRecord.setValue(false, forKey: "isCompleted", at: now) reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "remindersListID", at: now) + reminderRecord.setValue("1", forKey: "remindersListID", at: now) await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]) @@ -222,7 +222,7 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in - try Reminder.find(Reminder.ID(1)).delete().execute(db) + try Reminder.find(1).delete().execute(db) } } @@ -242,7 +242,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "00000000-0000-0000-0000-000000000001", + id: "1", title: "Personal" ) ] diff --git a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift index 0b471bbd..fa240a90 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift @@ -137,7 +137,7 @@ extension CKAsset: @retroactive CustomDumpReflectable { public var customDumpDescription: String { """ CKRecord.ID(\ - \(recordName.replacingOccurrences(of: "^[0-]+", with: "", options: .regularExpression))/\ + \(recordName)/\ \(zoneID.zoneName)/\ \(zoneID.ownerName)\ ) diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 1e6eb81c..d9b2cb4f 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -1,8 +1,11 @@ import Foundation import SharingGRDB +// NB: The IDs in this schema are integers for ease of testing. You should _not_ use integer IDs +// in a production application. + @Table struct Reminder: Equatable, Identifiable { - let id: Identifier + let id: Int var dueDate: Date? var isCompleted = false var priority: Int? @@ -10,88 +13,64 @@ import SharingGRDB var remindersListID: RemindersList.ID } @Table struct RemindersList: Equatable, Identifiable { - let id: Identifier + let id: Int var title = "" } @Table struct RemindersListAsset: Equatable, Identifiable { - let id: Identifier + let id: Int var coverImage: Data? var remindersListID: RemindersList.ID } @Table struct RemindersListPrivate: Equatable, Identifiable { - let id: Identifier + let id: Int var position = 0 var remindersListID: RemindersList.ID } @Table struct Tag: Equatable, Identifiable { - let id: Identifier + let id: Int var title = "" } @Table struct ReminderTag: Equatable, Identifiable { - let id: Identifier + let id: Int var reminderID: Reminder.ID var tagID: Tag.ID } @Table struct Parent: Equatable, Identifiable { - let id: UUID + let id: Int } @Table struct ChildWithOnDeleteRestrict: Equatable, Identifiable { - let id: UUID + let id: Int let parentID: Parent.ID } @Table struct ChildWithOnDeleteSetNull: Equatable, Identifiable { - let id: UUID + let id: Int let parentID: Parent.ID? } @Table struct ChildWithOnDeleteSetDefault: Equatable, Identifiable { - let id: UUID + let id: Int let parentID: Parent.ID } @Table struct LocalUser: Equatable, Identifiable { - let id: UUID + let id: Int var name = "" var parentID: LocalUser.ID? } @Table struct ModelA: Identifiable { - let id: UUID + let id: Int var count = 0 } @Table struct ModelB: Identifiable { - let id: UUID + let id: Int var isOn = false var modelAID: ModelA.ID } @Table struct ModelC: Identifiable { - let id: UUID + let id: Int var title = "" var modelBID: ModelB.ID } -struct Identifier: - ExpressibleByIntegerLiteral, - Hashable, - RawRepresentable, - IdentifierStringConvertible, - QueryBindable -{ - let rawValue: UUID - var rawIdentifier: String { rawValue.rawIdentifier } - init(rawValue: UUID) { - self.rawValue = rawValue - } - init?(rawIdentifier: String) { - guard let rawValue = UUID(uuidString: rawIdentifier) else { return nil } - self.init(rawValue: rawValue) - } - init(_ intValue: Int) { - self.init(rawValue: UUID(intValue)) - } - init(integerLiteral value: Int) { - self.init(value) - } -} - @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func database(containerIdentifier: String) throws -> DatabasePool { var configuration = Configuration() From 9dbb353ed68c15f12a8b5001b2f60322dc8fcfad Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 15 Jul 2025 17:37:32 -0700 Subject: [PATCH 383/581] wip --- Examples/CloudKitDemo/Schema.swift | 1 - Examples/CloudKitPlayground/Schema.swift | 1 - Examples/Reminders/Schema.swift | 1 - Examples/SyncUps/Schema.swift | 1 - .../CloudKit/CloudKit+StructuredQueries.swift | 4 +- .../CloudKit/Metadatabase.swift | 11 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 107 ++- .../UnsyncedRecordID+MacroExpansion.swift | 41 + .../CloudKit/UnsyncedRecordID.swift | 40 + .../CloudKitTests/AssetsTests.swift | 1 - .../CloudKitTests/CloudKitTests.swift | 4 +- .../FetchRecordZoneChangesTests.swift | 2 - .../ForeignKeyConstraintTests.swift | 706 ++++++++++++++++++ .../CloudKitTests/ForeignKeyTests.swift | 8 +- .../CloudKitTests/MergeConflictTests.swift | 2 - .../MockCloudDatabaseTests.swift | 2 - .../CloudKitTests/RecordTypeTests.swift | 4 +- .../CloudKitTests/SchemaChangeTests.swift | 1 - .../CloudKitTests/SharingTests.swift | 21 +- .../SyncEngineValidationTests.swift | 12 +- .../CloudKitTests/TriggerTests.swift | 127 ---- .../Internal/BaseCloudKitTests.swift | 5 + .../Internal/CloudKitTestHelpers.swift | 4 +- Tests/SharingGRDBTests/Internal/Schema.swift | 3 +- 24 files changed, 899 insertions(+), 210 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 5e0bf003..97357251 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -12,7 +12,6 @@ func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() - configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") #if DEBUG diff --git a/Examples/CloudKitPlayground/Schema.swift b/Examples/CloudKitPlayground/Schema.swift index e3647306..ab63de71 100644 --- a/Examples/CloudKitPlayground/Schema.swift +++ b/Examples/CloudKitPlayground/Schema.swift @@ -21,7 +21,6 @@ func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() - configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in try db.attachMetadatabase( containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 041ec70f..13e78d09 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -103,7 +103,6 @@ func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() - configuration.foreignKeysEnabled = context != .live configuration.prepareDatabase { db in try db.attachMetadatabase( containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.field-timestamps-2.Reminders" diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index 2d66f05d..dc075816 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -79,7 +79,6 @@ func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() - configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in #if DEBUG db.trace(options: .profile) { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index ef431e4b..385522a7 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -196,7 +196,7 @@ extension CKRecord { return false } - package func update(with row: T, userModificationDate: Date) { + func update(with row: T, userModificationDate: Date) { for column in T.TableColumns.writableColumns { func open(_ column: some WritableTableColumnExpression) { let column = column as! any WritableTableColumnExpression @@ -228,7 +228,7 @@ extension CKRecord { } } - package func update( + func update( with other: CKRecord, row: T, columnNames: inout [String] diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 465914e2..e04f8b94 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -88,6 +88,17 @@ func defaultMetadatabase( """ ) .execute(db) + try SQLQueryExpression( + """ + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( + "recordName" TEXT NOT NULL, + "zoneName" TEXT NOT NULL, + "ownerName" TEXT NOT NULL, + PRIMARY KEY ("recordName", "zoneName", "ownerName") + ) STRICT + """ + ) + .execute(db) } try migrator.migrate(metadatabase) return metadatabase diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ba31b109..34f47f4c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -89,13 +89,6 @@ privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { try validateSchema(tables: tables, userDatabase: userDatabase) - // TODO: Explain why / link to documentation? - precondition( - !userDatabase.configuration.foreignKeysEnabled, - """ - Foreign key support must be disabled to synchronize with CloudKit. - """ - ) self.container = container self.defaultSyncEngines = defaultSyncEngines self.userDatabase = userDatabase @@ -179,11 +172,11 @@ db: db ) } - for (childTableName, foreignKeys) in foreignKeysByTableName { - for foreignKey in foreignKeys { - try foreignKey.createTriggers(childTableName, belongsTo: foreignKey.table, db: db) - } - } +// for (childTableName, foreignKeys) in foreignKeysByTableName { +// for foreignKey in foreignKeys { +// try foreignKey.createTriggers(childTableName, belongsTo: foreignKey.table, db: db) +// } +// } } let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) @@ -341,11 +334,11 @@ async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() try await userDatabase.write { db in - for (childTableName, foreignKeys) in self.foreignKeysByTableName { - for foreignKey in foreignKeys { - try foreignKey.dropTriggers(for: childTableName, db: db) - } - } +// for (childTableName, foreignKeys) in self.foreignKeysByTableName { +// for foreignKey in foreignKeys { +// try foreignKey.dropTriggers(for: childTableName, db: db) +// } +// } for table in self.tables { try table.dropTriggers(db: db) } @@ -362,6 +355,7 @@ try SyncMetadata.delete().execute(db) try RecordType.delete().execute(db) try StateSerialization.delete().execute(db) + try UnsyncedRecordID.delete().execute(db) } _ = await (privateCancellation, sharedCancellation) } @@ -785,6 +779,48 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { + + let unsyncedRecords = await withErrorReporting(.sqliteDataCloudKitFailure) { + var unsyncedRecordIDs = try await userDatabase.write { db in + Set( + try UnsyncedRecordID.all + .fetchAll(db) + .map(CKRecord.ID.init(unsyncedRecordID:)) + ) + } + unsyncedRecordIDs.subtract(modifications.map(\.recordID)) + let results = try await syncEngine.database.records(for: Array(unsyncedRecordIDs)) + var unsyncedRecords: [CKRecord] = [] + for (recordID, result) in results { + switch result { + case .success(let record): + unsyncedRecords.append(record) + case .failure(let error as CKError) where error.code == .unknownItem: + try await userDatabase.write { db in + try UnsyncedRecordID.find(recordID).delete().execute(db) + } + case .failure: + continue + } + } + return unsyncedRecords + } + ?? [CKRecord]() + + if !unsyncedRecords.isEmpty { + print("!!") + } + + let modifications = (modifications + unsyncedRecords).sorted { lhs, rhs in + guard + let lhsRecordType = lhs.recordID.tableName, + let lhsIndex = tablesByOrder[lhsRecordType], + let rhsRecordType = rhs.recordID.tableName, + let rhsIndex = tablesByOrder[rhsRecordType] + else { return true } + return lhsIndex < rhsIndex + } + enum ShareOrReference { case share(CKShare) case reference(CKShare.Reference) @@ -990,20 +1026,17 @@ return } - let result = try metadatabase.read { db in + let metadata = try metadatabase.read { db in try SyncMetadata .where { $0.recordName.eq(serverRecord.recordID.recordName) } - .select { ($0, $0._lastKnownServerRecordAllFields) } .fetchOne(db) } - let metadata = result?.0 - let allFields = result?.1 serverRecord.userModificationDate = metadata?.userModificationDate ?? serverRecord.userModificationDate func open(_: T.Type) throws { var columnNames = T.TableColumns.writableColumns.map(\.name) - if let metadata, let allFields { + if let metadata, let allFields = metadata._lastKnownServerRecordAllFields { let row = try userDatabase.read { db in try T.find(SQLQueryExpression("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) } @@ -1026,12 +1059,27 @@ // TODO: Append more ON CONFLICT clauses for each unique constraint? // TODO: Use WHERE to scope the update? try userDatabase.write { db in - let query = upsert(T.self, record: serverRecord, columnNames: columnNames) - try SQLQueryExpression(query).execute(db) - try SyncMetadata - .where { $0.recordName.eq(serverRecord.recordID.recordName) } - .update { $0.setLastKnownServerRecord(serverRecord) } + do { + let query = upsert(T.self, record: serverRecord, columnNames: columnNames) + try SQLQueryExpression(query).execute(db) + try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db) + try SyncMetadata + .where { $0.recordName.eq(serverRecord.recordID.recordName) } + .update { $0.setLastKnownServerRecord(serverRecord) } + .execute(db) + } catch { + guard + let error = error as? DatabaseError, + error.resultCode == .SQLITE_CONSTRAINT, + error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY + else { + throw error + } + try UnsyncedRecordID.insert(or: .ignore) { + UnsyncedRecordID(recordID: serverRecord.recordID) + } .execute(db) + } } } try open(table) @@ -1512,16 +1560,17 @@ record: CKRecord, columnNames: some Collection ) -> QueryFragment { + let allColumnNames = T.TableColumns.writableColumns.map(\.name) guard columnNames.contains(where: { $0 != T.columns.primaryKey.name }) else { return "" } var query: QueryFragment = "INSERT INTO \(T.self) (" - query.append(columnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(allColumnNames.map { "\(quote: $0)" }.joined(separator: ", ")) query.append(") VALUES (") query.append( - columnNames + allColumnNames .map { columnName in if let asset = record[columnName] as? CKAsset { @Dependency(\.dataManager) var dataManager diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift new file mode 100644 index 00000000..ced3914e --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift @@ -0,0 +1,41 @@ +import StructuredQueries + +extension UnsyncedRecordID { + public struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = UnsyncedRecordID + public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) + public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) + public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName] + } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName] + } + public var queryFragment: QueryFragment { + "\(self.recordName), \(self.zoneName), \(self.ownerName)" + } + } +} + +extension UnsyncedRecordID: StructuredQueriesCore.Table { + public static let columns = TableColumns() + public static let tableName = "sqlitedata_icloud_unsyncedRecordIDs" + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let recordName = try decoder.decode(String.self) + let zoneName = try decoder.decode(String.self) + let ownerName = try decoder.decode(String.self) + guard let recordName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let zoneName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let ownerName else { + throw QueryDecodingError.missingRequiredColumn + } + self.recordName = recordName + self.zoneName = zoneName + self.ownerName = ownerName + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift new file mode 100644 index 00000000..54ab8361 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift @@ -0,0 +1,40 @@ +#if canImport(CloudKit) + import CloudKit + import StructuredQueriesCore + + // @Table("\(String.sqliteDataCloudKitSchemaName)_unsyncedRecordIDs") + package struct UnsyncedRecordID { + package let recordName: String + package let zoneName: String + package let ownerName: String + } + + extension UnsyncedRecordID { + package init(recordID: CKRecord.ID) { + recordName = recordID.recordName + zoneName = recordID.zoneID.zoneName + ownerName = recordID.zoneID.ownerName + } + package static func find(_ recordID: CKRecord.ID) -> Where { + Self.where { + $0.recordName.eq(recordID.recordName) + && $0.zoneName.eq(recordID.zoneID.zoneName) + && $0.ownerName.eq(recordID.zoneID.ownerName) + } + } + } + + extension CKRecord.ID { + convenience init(unsyncedRecordID: UnsyncedRecordID) { + self.init( + recordName: unsyncedRecordID.recordName, + zoneID: + CKRecordZone + .ID( + zoneName: unsyncedRecordID.zoneName, + ownerName: unsyncedRecordID.ownerName + ) + ) + } + } +#endif diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index 16c3ce05..7d765add 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -10,7 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class AssetsTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now @Dependency(\.dataManager) var dataManager var inMemoryDataManager: InMemoryDataManager { dataManager as! InMemoryDataManager diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 10d53d79..337945b4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -10,8 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() throws { let zones = try userDatabase.userRead { db in @@ -120,7 +118,7 @@ extension BaseCloudKitTests { "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 5a7ab42a..ebc9312a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -11,8 +11,6 @@ extension BaseCloudKitTests { @MainActor @Suite final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @Test func saveExtraFieldsToSyncMetadata() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift new file mode 100644 index 00000000..ee497f35 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -0,0 +1,706 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class ForeignKeyConstraintTests: BaseCloudKitTests, @unchecked Sendable { + @Test func receiveChildBeforeParent() 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) + + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + + let remindersListModification = { + syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + }() + await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__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() + + try { + try userDatabase.read { db in + let reminderMetadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + ) + #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + + let remindersListMetadata = try #require( + try SyncMetadata.find(1, table: RemindersList.self).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.date.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) + } + + await syncEngine.processBatch() + } + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Buy milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try 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)) + } + }() + } + + @Test func receiveChild_Relaunch_ReceiveParent() 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) + + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + + _ = { syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) }() + await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__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, + metadatabaseURL: URL(filePath: syncEngine.metadatabase.path), + tables: syncEngine.tables, + privateTables: syncEngine.privateTables + ) + + await relaunchedSyncEngine + .handleEvent( + .fetchedRecordZoneChanges(modifications: [remindersListRecord], deletions: []), + syncEngine: relaunchedSyncEngine.private + ) + + try { + try userDatabase.read { db in + let reminderMetadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + ) + #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + + let remindersListMetadata = try #require( + try SyncMetadata.find(1, table: RemindersList.self).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.date.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) + } + + await relaunchedSyncEngine.processBatch() + } + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Buy milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try 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)) + } + }() + } + + @Test( + """ + Remote changes parent relationship to an unknown record which is synchronized later. + """ + ) + func changeParentRelationshipToUnknownRecord() async throws { + let personalListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + personalListRecord.setValue(1, forKey: "id", at: now) + personalListRecord.setValue("Personal", forKey: "title", at: now) + + let businessListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 2) + ) + businessListRecord.setValue(2, forKey: "id", at: now) + businessListRecord.setValue("Business", forKey: "title", at: now) + + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + + await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord, personalListRecord]) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let modifications = try await withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: businessListRecord, action: .none) + + let modifications = { + syncEngine.modifyRecords(scope: .private, saving: [businessListRecord]) + }() + await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + return modifications + } + + await modifications() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 2, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ), + [2]: CKRecord( + recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 2, + title: "Business" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + _ = try { + try 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 SyncMetadata.find(1, table: Reminder.self) + .fetchOne(db) + ) + print("!!!") + } + }() + + // 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() + // + // try { + // try userDatabase.read { db in + // let reminderMetadata = try #require( + // try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + // ) + // #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) + // #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + // + // let remindersListMetadata = try #require( + // try SyncMetadata.find(1, table: RemindersList.self).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.date.now.addTimeInterval(1) + // } operation: { + // try await userDatabase.userWrite { db in + // try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) + // } + // + // await syncEngine.processBatch() + // } + // + // assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + // """ + // MockCloudContainer( + // privateCloudDatabase: MockCloudDatabase( + // databaseScope: .private, + // storage: [ + // [0]: CKRecord( + // recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + // recordType: "reminders", + // parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + // share: nil, + // id: 1, + // isCompleted: 0, + // remindersListID: 1, + // title: "Buy milk" + // ), + // [1]: CKRecord( + // recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + // recordType: "remindersLists", + // parent: nil, + // share: nil, + // id: 1, + // title: "Personal" + // ) + // ] + // ), + // sharedCloudDatabase: MockCloudDatabase( + // databaseScope: .shared, + // storage: [] + // ) + // ) + // """ + // } + // + // try { + // try 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)) + // } + // }() + } + + @Test func changeParentRelationship_RemotelyThenLocally() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + RemindersList(id: 3, title: "Secret") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + await syncEngine.processBatch() + + let modifications = try withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 2), + action: .none + ) + return syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } + + try await withDependencies { + $0.date.now.addTimeInterval(2) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1) + .update { + $0.title = "Buy milk" + $0.remindersListID = 3 + } + .execute(db) + } + } + + await modifications() + + try { + try userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).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)) + } + }() + + await syncEngine.processBatch() + + assertInlineSnapshot( + of: syncEngine.private.database.storage[Reminder.recordID(for: 1)], + as: .customDump + ) { + """ + CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 3, + title: "Buy milk" + ) + """ + } + + try { + try userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).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)) + } + }() + } + + @Test + func changeParentRelationship_RemoteFirstEdited_LocalSecondEdited_SendBatch_ReceiveCloudKit() + async throws + { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + RemindersList(id: 3, title: "Secret") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + await syncEngine.processBatch() + + let modifications = try withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 2), + action: .none + ) + return syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } + + try await withDependencies { + $0.date.now.addTimeInterval(2) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.remindersListID = 3 }.execute(db) + } + } + + await syncEngine.processBatch() + + try { + try userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).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() + + assertInlineSnapshot( + of: syncEngine.private.database.storage[Reminder.recordID(for: 1)], + as: .customDump + ) { + """ + CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 3, + title: "Get milk" + ) + """ + } + + await syncEngine.processBatch() + + assertInlineSnapshot( + of: syncEngine.private.database.storage[Reminder.recordID(for: 1)], + as: .customDump + ) { + """ + CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 3, + title: "Get milk" + ) + """ + } + + try { + try userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).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)) + } + }() + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 5f16e2c2..4c6262a1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -72,9 +72,11 @@ extension BaseCloudKitTests { try RemindersList.find(1).delete().execute(db) } } - try await userDatabase.userRead { db in - try #expect(Reminder.all.fetchAll(db) == []) - } + try { + try userDatabase.userRead { db in + try #expect(Reminder.all.fetchAll(db) == []) + } + }() await syncEngine.processBatch() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index cb9fd0c7..0635afe6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -10,8 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index 67f83f25..6e4e1a90 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -10,8 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class MockCloudDatabaseTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func saveTransaction_ChildBeforeParent() async throws { let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 32bb3f95..19881854 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -117,7 +117,7 @@ extension BaseCloudKitTests { "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -500,7 +500,7 @@ extension BaseCloudKitTests { "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, + "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index c728a917..ee35ae13 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -9,7 +9,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class SchemaChangeTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now @Dependency(\.dataManager) var dataManager var inMemoryDataManager: InMemoryDataManager { dataManager as! InMemoryDataManager diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index cb93c4aa..d9eed997 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -10,8 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class SharingTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareNonRootRecord() async throws { let reminder = Reminder(id: 1, title: "Groceries", remindersListID: 1) @@ -132,7 +130,7 @@ extension BaseCloudKitTests { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) remindersListRecord.setValue(false, forKey: "isCompleted", at: now) @@ -194,21 +192,8 @@ extension BaseCloudKitTests { 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-00000000-0000-0000-0000-000000000001/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-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), - id: "00000000-0000-0000-0000-000000000001", - isCompleted: 0, - title: "Personal" - ), + lastKnownServerRecord: nil, + _lastKnownServerRecordAllFields: nil, share: nil, isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 8e843122..c5f63b49 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -17,9 +17,7 @@ extension BaseCloudKitTests { @Test func tableNameValidation() async throws { let error = try #require( await #expect(throws: InvalidTableName.self) { - var configuration = Configuration() - configuration.foreignKeysEnabled = false - let database = try DatabaseQueue(configuration: configuration) + let database = try DatabaseQueue() _ = try await SyncEngine( container: MockCloudContainer( containerIdentifier: "deadbeef", @@ -44,9 +42,7 @@ extension BaseCloudKitTests { @Test func userTriggerValidation() async throws { let error = try await #require( #expect(throws: InvalidUserTriggers.self) { - var configuration = Configuration() - configuration.foreignKeysEnabled = false - let database = try DatabaseQueue(configuration: configuration) + let database = try DatabaseQueue() try await database.write { db in try #sql( """ @@ -102,9 +98,7 @@ extension BaseCloudKitTests { } @Test func doNotValidateTriggersOnNonSyncedTables() async throws { - var configuration = Configuration() - configuration.foreignKeysEnabled = false - let database = try DatabaseQueue(configuration: configuration) + let database = try DatabaseQueue() try await database.write { db in try #sql( """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index d57d6a22..8537ef01 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -465,133 +465,6 @@ extension BaseCloudKitTests { ON CONFLICT ("recordPrimaryKey", "recordType") DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END - """, - [42]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onDeleteRestrict" - BEFORE DELETE ON "parents" - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "childWithOnDeleteRestricts" - WHERE "parentID" = "old"."id"; - END - """, - [43]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteRestricts_belongsTo_parents_onUpdateRestrict" - BEFORE UPDATE ON "parents" - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM "childWithOnDeleteRestricts" - WHERE "parentID" = "old"."id"; - END - """, - [44]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onDeleteSetDefault" - AFTER DELETE ON "parents" - FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetDefaults" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; - END - """, - [45]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetDefaults_belongsTo_parents_onUpdateSetDefault" - AFTER UPDATE ON "parents" - FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetDefaults" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; - END - """, - [46]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onDeleteSetNull" - AFTER DELETE ON "parents" - FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetNulls" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; - END - """, - [47]: """ - CREATE TRIGGER "sqlitedata_icloud_childWithOnDeleteSetNulls_belongsTo_parents_onUpdateSetNull" - AFTER UPDATE ON "parents" - FOR EACH ROW BEGIN - UPDATE "childWithOnDeleteSetNulls" - SET "parentID" = NULL - WHERE "parentID" = "old"."id"; - END - """, - [48]: """ - CREATE TRIGGER "sqlitedata_icloud_localUsers_belongsTo_localUsers_onDeleteCascade" - AFTER DELETE ON "localUsers" - FOR EACH ROW BEGIN - DELETE FROM "localUsers" - WHERE "parentID" = "old"."id"; - END - """, - [49]: """ - CREATE TRIGGER "sqlitedata_icloud_modelBs_belongsTo_modelAs_onDeleteCascade" - AFTER DELETE ON "modelAs" - FOR EACH ROW BEGIN - DELETE FROM "modelBs" - WHERE "modelAID" = "old"."id"; - END - """, - [50]: """ - CREATE TRIGGER "sqlitedata_icloud_modelCs_belongsTo_modelBs_onDeleteCascade" - AFTER DELETE ON "modelBs" - FOR EACH ROW BEGIN - DELETE FROM "modelCs" - WHERE "modelBID" = "old"."id"; - END - """, - [51]: """ - CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_reminders_onDeleteCascade" - AFTER DELETE ON "reminders" - FOR EACH ROW BEGIN - DELETE FROM "reminderTags" - WHERE "reminderID" = "old"."id"; - END - """, - [52]: """ - CREATE TRIGGER "sqlitedata_icloud_reminderTags_belongsTo_tags_onDeleteCascade" - AFTER DELETE ON "tags" - FOR EACH ROW BEGIN - DELETE FROM "reminderTags" - WHERE "tagID" = "old"."id"; - END - """, - [53]: """ - CREATE TRIGGER "sqlitedata_icloud_remindersListAssets_belongsTo_remindersLists_onDeleteCascade" - AFTER DELETE ON "remindersLists" - FOR EACH ROW BEGIN - DELETE FROM "remindersListAssets" - WHERE "remindersListID" = "old"."id"; - END - """, - [54]: """ - CREATE TRIGGER "sqlitedata_icloud_remindersListPrivates_belongsTo_remindersLists_onDeleteCascade" - AFTER DELETE ON "remindersLists" - FOR EACH ROW BEGIN - DELETE FROM "remindersListPrivates" - WHERE "remindersListID" = "old"."id"; - END - """, - [55]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onDeleteCascade" - AFTER DELETE ON "remindersLists" - FOR EACH ROW BEGIN - DELETE FROM "reminders" - WHERE "remindersListID" = "old"."id"; - END - """, - [56]: """ - CREATE TRIGGER "sqlitedata_icloud_reminders_belongsTo_remindersLists_onUpdateCascade" - AFTER UPDATE ON "remindersLists" - FOR EACH ROW BEGIN - UPDATE "reminders" - SET "remindersListID" = "new"."id" - WHERE "remindersListID" = "old"."id"; - END """ ] """# diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 4889d3d4..46bb1bba 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -17,6 +17,8 @@ class BaseCloudKitTests: @unchecked Sendable { let userDatabase: UserDatabase private let _syncEngine: any Sendable + @Dependency(\.date.now) var now + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var syncEngine: SyncEngine { _syncEngine as! SyncEngine @@ -74,6 +76,9 @@ class BaseCloudKitTests: @unchecked Sendable { syncEngine.private.state.assertPendingDatabaseChanges([]) syncEngine.private.state.assertPendingRecordZoneChanges([]) syncEngine.private.assertAcceptedShareMetadata([]) + try! userDatabase.read { db in + try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) + } } else { Issue.record("Tests must be run on iOS 17+, macOS 14+, tvOS 17+ and watchOS 10+.") } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 90863751..1f50fa39 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -249,15 +249,13 @@ final class MockCloudDatabase: CloudDatabase { let key: String } - struct RecordNotFound: Error {} - init(databaseScope: CKDatabase.Scope) { self.databaseScope = databaseScope } func record(for recordID: CKRecord.ID) throws -> CKRecord { guard let record = storage[recordID] - else { throw RecordNotFound() } + else { throw CKError(.unknownItem) } guard let record = record.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index d9b2cb4f..366b5414 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -74,7 +74,6 @@ import SharingGRDB @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func database(containerIdentifier: String) throws -> DatabasePool { var configuration = Configuration() - configuration.foreignKeysEnabled = false configuration.prepareDatabase { db in try db.attachMetadatabase(containerIdentifier: containerIdentifier) db.trace { @@ -121,7 +120,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT From eb6a8590535e610bbc3febacbbf5896fa25ee5b0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 15 Jul 2025 17:40:03 -0700 Subject: [PATCH 384/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 34f47f4c..bdedf48a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -172,11 +172,6 @@ db: db ) } -// for (childTableName, foreignKeys) in foreignKeysByTableName { -// for foreignKey in foreignKeys { -// try foreignKey.createTriggers(childTableName, belongsTo: foreignKey.table, db: db) -// } -// } } let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) @@ -334,11 +329,6 @@ async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() try await userDatabase.write { db in -// for (childTableName, foreignKeys) in self.foreignKeysByTableName { -// for foreignKey in foreignKeys { -// try foreignKey.dropTriggers(for: childTableName, db: db) -// } -// } for table in self.tables { try table.dropTriggers(db: db) } @@ -807,10 +797,6 @@ } ?? [CKRecord]() - if !unsyncedRecords.isEmpty { - print("!!") - } - let modifications = (modifications + unsyncedRecords).sorted { lhs, rhs in guard let lhsRecordType = lhs.recordID.tableName, From 5c7abc1f6951e8535e65cc1b76cc7ebdea67e4fa Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 15 Jul 2025 18:00:43 -0700 Subject: [PATCH 385/581] a test for deletion --- .../ForeignKeyConstraintTests.swift | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index ee497f35..fac9aa01 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -299,6 +299,57 @@ extension BaseCloudKitTests { }() } + @Test( + """ + * The local client creates a reminder in a list. + * The remote client deletes that list. + """ + ) func moveReminderToList_RemoteDeletesList() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + await syncEngine.processBatch() + + try withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + + let modifications = { syncEngine.modifyRecords(scope: .private, deleting: [RemindersList.recordID(for: 1)]) }() + await syncEngine.processBatch() + await modifications() + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try userDatabase.read { db in + try #expect(Reminder.count().fetchOne(db) == 0) + try #expect(RemindersList.count().fetchOne(db) == 0) + } + }() + } + @Test( """ Remote changes parent relationship to an unknown record which is synchronized later. @@ -434,7 +485,7 @@ extension BaseCloudKitTests { try SyncMetadata.find(1, table: Reminder.self) .fetchOne(db) ) - print("!!!") + #expect(reminderMetadata.parentRecordName == "2:remindersLists") } }() From 2a08830c32c67755362d8c1ecbd205f10f244e2b Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Wed, 16 Jul 2025 08:11:44 -0400 Subject: [PATCH 386/581] Fixes #32 failure to compile in release configuration --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ba31b109..b9d75cdc 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -610,10 +610,10 @@ #endif let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in - #if DEBUG var missingTable: CKRecord.ID? var missingRecord: CKRecord.ID? var sentRecord: CKRecord.ID? + #if DEBUG defer { state.withValue { [missingTable, missingRecord, sentRecord] in if let missingTable { $0.missingTables.append(missingTable) } From e97b68f14f5a5cd2b431edff98a735b29972c14b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 10:47:39 -0700 Subject: [PATCH 387/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 49 ++++-- .../CloudKitTests/CloudKitTests.swift | 1 + .../ForeignKeyConstraintTests.swift | 144 +++++++----------- 3 files changed, 94 insertions(+), 100 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index bdedf48a..5c28a258 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -846,20 +846,22 @@ // TODO: Group by recordType and delete in batches for (recordID, recordType) in deletions { + guard let recordPrimaryKey = recordID.recordPrimaryKey + else { continue } if let table = tablesByName[recordType] { func open(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.write { db in - try T - .where { - SQLQueryExpression("\($0.primaryKey)").eq( - SyncMetadata - .where { $0.recordName.eq(recordID.recordName) } - .select(\.recordPrimaryKey) - ) - } - .delete() - .execute(db) +// try SyncEngine.$_isUpdatingRecord.withValue(false) { + try T + .where { + SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") + } + .delete() + .execute(db) +// } + print("!!!") } } } @@ -923,8 +925,33 @@ case .serverRejectedRequest: clearServerRecord() + case .referenceViolation: + // TODO: look up FK for parent relationship, if "DELETE CASCADE" then delete, else set NULL + //reportIssue("Reference violation") + await withErrorReporting { + try await userDatabase.write { db in + guard + let table = self.tablesByName[failedRecord.recordType], + let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey + else { return } + func open(_: T.Type) throws { + try Self.$_isUpdatingRecord.withValue(false) { + let q = try T + .where { SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") } + .delete() + print("!!!") + try q + .execute(db) + } + } + try open(table) + } + } + print("!!!") + break + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, - .notAuthenticated, .referenceViolation, .operationCancelled, .batchRequestFailed, + .notAuthenticated, .operationCancelled, .batchRequestFailed, .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, .permissionFailure, .invalidArguments, .resultsTruncated, .assetFileNotFound, .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired, diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 337945b4..9234a77c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -912,6 +912,7 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]) + await syncEngine.processBatch() #expect( try await userDatabase.userRead { db in diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index fac9aa01..f8f3d6bc 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -301,29 +301,64 @@ extension BaseCloudKitTests { @Test( """ - * The local client creates a reminder in a list. + * The local client moves a reminder to a list. * The remote client deletes that list. """ ) func moveReminderToList_RemoteDeletesList() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) } } await syncEngine.processBatch() + let modifications = { + syncEngine.modifyRecords(scope: .private, deleting: [RemindersList.recordID(for: 2)]) + }() try withDependencies { $0.date.now.addTimeInterval(1) } operation: { try userDatabase.userWrite { db in - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } + try Reminder.find(1).update { $0.remindersListID = 2 }.execute(db) } } - let modifications = { syncEngine.modifyRecords(scope: .private, deleting: [RemindersList.recordID(for: 1)]) }() await syncEngine.processBatch() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } await modifications() await syncEngine.processBatch() @@ -332,7 +367,16 @@ extension BaseCloudKitTests { MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, - storage: [] + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] ), sharedCloudDatabase: MockCloudDatabase( databaseScope: .shared, @@ -345,7 +389,11 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in try #expect(Reminder.count().fetchOne(db) == 0) - try #expect(RemindersList.count().fetchOne(db) == 0) + try #expect( + RemindersList.all.fetchAll(db) == [ + RemindersList(id: 1, title: "Personal") + ] + ) } }() } @@ -488,88 +536,6 @@ extension BaseCloudKitTests { #expect(reminderMetadata.parentRecordName == "2:remindersLists") } }() - - // 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() - // - // try { - // try userDatabase.read { db in - // let reminderMetadata = try #require( - // try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) - // ) - // #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) - // #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - // - // let remindersListMetadata = try #require( - // try SyncMetadata.find(1, table: RemindersList.self).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.date.now.addTimeInterval(1) - // } operation: { - // try await userDatabase.userWrite { db in - // try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) - // } - // - // await syncEngine.processBatch() - // } - // - // assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - // """ - // MockCloudContainer( - // privateCloudDatabase: MockCloudDatabase( - // databaseScope: .private, - // storage: [ - // [0]: CKRecord( - // recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - // recordType: "reminders", - // parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - // share: nil, - // id: 1, - // isCompleted: 0, - // remindersListID: 1, - // title: "Buy milk" - // ), - // [1]: CKRecord( - // recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - // recordType: "remindersLists", - // parent: nil, - // share: nil, - // id: 1, - // title: "Personal" - // ) - // ] - // ), - // sharedCloudDatabase: MockCloudDatabase( - // databaseScope: .shared, - // storage: [] - // ) - // ) - // """ - // } - // - // try { - // try 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)) - // } - // }() } @Test func changeParentRelationship_RemotelyThenLocally() async throws { From e04ad1be0eb326ebee4c8f03e39733e604ece4d4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 11:50:57 -0700 Subject: [PATCH 388/581] Fix a reference violation. --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 115 ++++++++++--- .../CloudKitTests/CloudKitTests.swift | 2 +- .../CloudKitTests/RecordTypeTests.swift | 4 +- .../ReferenceViolationTests.swift | 159 ++++++++++++++++++ .../CloudKitTests/SharingTests.swift | 42 ++--- .../Internal/CloudKitTestHelpers.swift | 4 +- Tests/SharingGRDBTests/Internal/Schema.swift | 2 +- 7 files changed, 276 insertions(+), 52 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ba31b109..8586e3e6 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -88,24 +88,6 @@ tables: [any PrimaryKeyedTable.Type], privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { - try validateSchema(tables: tables, userDatabase: userDatabase) - // TODO: Explain why / link to documentation? - precondition( - !userDatabase.configuration.foreignKeysEnabled, - """ - Foreign key support must be disabled to synchronize with CloudKit. - """ - ) - self.container = container - self.defaultSyncEngines = defaultSyncEngines - self.userDatabase = userDatabase - self.logger = logger - self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) - let tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) - .map(\.type) - self.tables = tables - self.privateTables = privateTables - let allTables = try userDatabase.read { db in try SQLQueryExpression( """ @@ -115,9 +97,7 @@ ) .fetchAll(db) } - - self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) - self.foreignKeysByTableName = Dictionary( + let foreignKeysByTableName = Dictionary( uniqueKeysWithValues: try userDatabase.read { db in try allTables.map { table -> (String, [ForeignKey]) in ( @@ -127,6 +107,30 @@ } } ) + try validateSchema( + tables: tables, + foreignKeysByTableName: foreignKeysByTableName, + userDatabase: userDatabase + ) + // TODO: Explain why / link to documentation? + precondition( + !userDatabase.configuration.foreignKeysEnabled, + """ + Foreign key support must be disabled to synchronize with CloudKit. + """ + ) + self.container = container + self.defaultSyncEngines = defaultSyncEngines + self.userDatabase = userDatabase + self.logger = logger + self.metadatabase = try defaultMetadatabase(logger: logger, url: metadatabaseURL) + let tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) + .map(\.type) + self.tables = tables + self.privateTables = privateTables + + self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) + self.foreignKeysByTableName = foreignKeysByTableName tablesByOrder = try SharingGRDBCore.tablesByOrder( userDatabase: userDatabase, tables: tables, @@ -901,8 +905,49 @@ case .serverRejectedRequest: clearServerRecord() + case .referenceViolation: + guard + let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey, + let table = tablesByName[failedRecord.recordType], + foreignKeysByTableName[table.tableName]?.count == 1, + let foreignKey = foreignKeysByTableName[table.tableName]?.first + else { continue } + func open(_: T.Type) throws { + try userDatabase.write { db in + try Self.$_isUpdatingRecord.withValue(false) { + switch foreignKey.onDelete { + case .cascade: + try T + .where { SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") } + .delete() + .execute(db) + case .restrict: + // TODO: validate schema + break + case .setDefault: + // TODO: do this + break + case .setNull: + try SQLQueryExpression(""" + UPDATE \(T.self) + SET \(quote: foreignKey.from, delimiter: .identifier) = NULL + WHERE \(T.primaryKey) = \(bind: recordPrimaryKey) + """) + .execute(db) + case .noAction: + // TODO: validate schema + break + } + } + } + } + withErrorReporting(.sqliteDataCloudKitFailure) { + try open(table) + } + + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, - .notAuthenticated, .referenceViolation, .operationCancelled, .batchRequestFailed, + .notAuthenticated, .operationCancelled, .batchRequestFailed, .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, .permissionFailure, .invalidArguments, .resultsTruncated, .assetFileNotFound, .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired, @@ -1322,6 +1367,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func validateSchema( tables: [any PrimaryKeyedTable.Type], + foreignKeysByTableName: [String: [ForeignKey]], userDatabase: UserDatabase ) throws { let tableNames = Set(tables.map { $0.tableName }) @@ -1357,6 +1403,15 @@ throw InvalidUserTriggers(triggers: invalidTriggers) } + for (tableName, foreignKeys) in foreignKeysByTableName { + if + foreignKeys.count == 1, + [.restrict, .noAction].contains(foreignKeys[0].onDelete) + { + + } + } + for table in tables { // // TODO: write tests for this // let columnsWithUniqueConstraints = @@ -1389,12 +1444,24 @@ } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +public struct InvalidTableName: LocalizedError { + let tableName: String + public var localizedDescription: String { + """ + Table name \(tableName.debugDescription) contains invalid character ':'. + """ + } +} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public struct InvalidTableName: LocalizedError { + public struct InvalidParentForeignKey: LocalizedError { let tableName: String + let foreignKey: ForeignKey public var localizedDescription: String { """ - Table name \(tableName.debugDescription) contains invalid character ':'. + Foreign key \(tableName.debugDescription).\(foreignKey.from) action not supported. Must + be 'CASCADE', 'SET DEFAULT' or 'SET NULL'. """ } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 10d53d79..562ab7f7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -120,7 +120,7 @@ extension BaseCloudKitTests { "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 32bb3f95..19881854 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -117,7 +117,7 @@ extension BaseCloudKitTests { "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -500,7 +500,7 @@ extension BaseCloudKitTests { "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, + "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift new file mode 100644 index 00000000..88f6adb8 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -0,0 +1,159 @@ +import CloudKit +import ConcurrencyExtras +import CustomDump +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class ReferenceViolationTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test( + """ + * The local client moves a reminder to a list. + * The remote client deletes that list. + """ + ) func moveReminderToList_RemoteDeletesList() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + await syncEngine.processBatch() + + let modifications = { + syncEngine.modifyRecords(scope: .private, deleting: [RemindersList.recordID(for: 2)]) + }() + try withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try userDatabase.userWrite { db in + try Reminder.find(1).update { $0.remindersListID = 2 }.execute(db) + } + } + + await syncEngine.processBatch() + await modifications() + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try userDatabase.read { db in + try #expect(Reminder.count().fetchOne(db) == 0) + try #expect( + RemindersList.all.fetchAll(db) == [ + RemindersList(id: 1, title: "Personal") + ] + ) + } + }() + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test( + """ + * The local client moves child to parent. + * The remote client deletes parent. + * Local client sets parent relationship to NULL. + """ + ) func moveChildToParent_RemoteDeletesParent_CascadeSetNull() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Parent(id: 1) + Parent(id: 2) + ChildWithOnDeleteSetNull(id: 1, parentID: 1) + } + } + await syncEngine.processBatch() + + let modifications = { + syncEngine.modifyRecords(scope: .private, deleting: [Parent.recordID(for: 2)]) + }() + try withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try userDatabase.userWrite { db in + try ChildWithOnDeleteSetNull.find(1).update { $0.parentID = 2 }.execute(db) + } + } + try await withDependencies { + $0.date.now.addTimeInterval(2) + } operation: { + await syncEngine.processBatch() + await modifications() + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "childWithOnDeleteSetNulls", + parent: nil, + share: nil, + id: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + try { + try userDatabase.read { db in + try #expect( + ChildWithOnDeleteSetNull.all.fetchAll(db) == [ + ChildWithOnDeleteSetNull(id: 1, parentID: nil) + ] + ) + try #expect( + Parent.all.fetchAll(db) == [ + Parent(id: 1) + ] + ) + } + }() + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index cb93c4aa..1f5319d5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -33,7 +33,7 @@ extension BaseCloudKitTests { @Test func shareUnrecognizedTable() async throws { await #expect(throws: SyncEngine.UnrecognizedTable.self) { _ = try await self.syncEngine.share( - record: NonSyncedTable(id: UUID()), + record: NonSyncedTable(id: 42), configure: { _ in } ) } @@ -70,7 +70,7 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) - remindersListRecord.setValue("1", forKey: "id", at: now) + remindersListRecord.setValue(1, forKey: "id", at: now) remindersListRecord.setValue(false, forKey: "isCompleted", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) @@ -112,7 +112,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "1", + id: 1, isCompleted: 0, title: "Personal" ) @@ -132,16 +132,16 @@ extension BaseCloudKitTests { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) - remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue(1, forKey: "id", at: now) remindersListRecord.setValue(false, forKey: "isCompleted", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) let share = CKShare.init( rootRecord: remindersListRecord, shareID: CKRecord.ID( - recordName: "Share-\(UUID(1).uuidString.lowercased())", + recordName: "Share-\(1)", zoneID: externalZoneID ) ) @@ -161,7 +161,7 @@ extension BaseCloudKitTests { databaseScope: .shared, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/external.zone/external.owner), + recordID: CKRecord.ID(Share-1/external.zone/external.owner), recordType: "cloudkit.share", parent: nil, share: nil @@ -170,8 +170,8 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), - id: "00000000-0000-0000-0000-000000000001", + share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), + id: 1, isCompleted: 0, title: "Personal" ) @@ -188,9 +188,9 @@ extension BaseCloudKitTests { """ [ [0]: SyncMetadata( - recordPrimaryKey: "00000000-0000-0000-0000-000000000001", + recordPrimaryKey: "1", recordType: "remindersLists", - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + recordName: "1:remindersLists", parentRecordPrimaryKey: nil, parentRecordType: nil, parentRecordName: nil, @@ -198,14 +198,14 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/external.zone/external.owner)) + share: CKReference(recordID: CKRecord.ID(Share-1/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-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), - id: "00000000-0000-0000-0000-000000000001", + share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), + id: 1, isCompleted: 0, title: "Personal" ), @@ -229,7 +229,7 @@ extension BaseCloudKitTests { recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1, zoneID: externalZoneID) ) - modelARecord.setValue("1", forKey: "id", at: now) + modelARecord.setValue(1, forKey: "id", at: now) modelARecord.setValue(0, forKey: "count", at: now) await syncEngine.modifyRecords(scope: .shared, saving: [modelARecord]) @@ -262,7 +262,7 @@ extension BaseCloudKitTests { parent: nil, share: nil, count: 0, - id: "1" + id: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), @@ -300,16 +300,16 @@ extension BaseCloudKitTests { recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) - remindersListRecord.setValue("1", forKey: "id", at: now) + remindersListRecord.setValue(1, forKey: "id", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) let reminderRecord = CKRecord( recordType: Reminder.tableName, recordID: Reminder.recordID(for: 1, zoneID: externalZoneID) ) - reminderRecord.setValue("1", forKey: "id", at: now) + reminderRecord.setValue(1, forKey: "id", at: now) reminderRecord.setValue(false, forKey: "isCompleted", at: now) reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue("1", forKey: "remindersListID", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]) @@ -337,7 +337,7 @@ extension BaseCloudKitTests { recordType: "remindersLists", parent: nil, share: nil, - id: "1", + id: 1, title: "Personal" ) ] @@ -352,5 +352,5 @@ extension BaseCloudKitTests { // TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list @Table private struct NonSyncedTable { - let id: UUID + let id: Int } diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 90863751..1f50fa39 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -249,15 +249,13 @@ final class MockCloudDatabase: CloudDatabase { let key: String } - struct RecordNotFound: Error {} - init(databaseScope: CKDatabase.Scope) { self.databaseScope = databaseScope } func record(for recordID: CKRecord.ID) throws -> CKRecord { guard let record = storage[recordID] - else { throw RecordNotFound() } + else { throw CKError(.unknownItem) } guard let record = record.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index d9b2cb4f..ac045f5b 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -121,7 +121,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT From 438f25f23d7e963e0b84aaee456ca943a3c55cf7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 12:06:15 -0700 Subject: [PATCH 389/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 21 +- .../CloudKitTests/CloudKitTests.swift | 169 +++++++++-------- .../CloudKitTests/RecordTypeTests.swift | 179 ++++++++++-------- .../ReferenceViolationTests.swift | 87 +++++++++ .../CloudKitTests/TriggerTests.swift | 4 +- Tests/SharingGRDBTests/Internal/Schema.swift | 47 ++--- 6 files changed, 325 insertions(+), 182 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 8586e3e6..7f0bf1ca 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -922,10 +922,22 @@ .delete() .execute(db) case .restrict: - // TODO: validate schema + reportIssue( + "'RESTRICT' foreign key actions not supported for parent relationships." + ) break case .setDefault: - // TODO: do this + guard + let recordType = try RecordType.find(table.tableName).fetchOne(db), + let columnInfo = recordType.tableInfo.first(where: { $0.name == foreignKey.from }) + else { return } + let defaultValue = columnInfo.defaultValue ?? "NULL" + try SQLQueryExpression(""" + UPDATE \(T.self) + SET \(quote: foreignKey.from, delimiter: .identifier) = (\(raw: defaultValue)) + WHERE \(T.primaryKey) = \(bind: recordPrimaryKey) + """) + .execute(db) break case .setNull: try SQLQueryExpression(""" @@ -935,8 +947,9 @@ """) .execute(db) case .noAction: - // TODO: validate schema - break + reportIssue( + "'NO ACTION' foreign key actions not supported for parent relationships." + ) } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 562ab7f7..68c6685e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -24,17 +24,17 @@ extension BaseCloudKitTests { tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -46,12 +46,32 @@ extension BaseCloudKitTests { ] ), [1]: RecordType( + tableName: "sqlite_sequence", + schema: "CREATE TABLE sqlite_sequence(name,seq)", + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "name", + notNull: false, + type: "" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "seq", + notNull: false, + type: "" + ) + ] + ), + [2]: RecordType( tableName: "remindersListAssets", schema: """ CREATE TABLE "remindersListAssets" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "coverImage" BLOB NOT NULL, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ @@ -63,37 +83,37 @@ extension BaseCloudKitTests { type: "BLOB" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [2]: RecordType( + [3]: RecordType( tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", @@ -107,20 +127,20 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [3]: RecordType( + [4]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -134,11 +154,11 @@ extension BaseCloudKitTests { type: "TEXT" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: "0", @@ -159,7 +179,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [5]: TableInfo( defaultValue: "\'\'", @@ -170,21 +190,21 @@ extension BaseCloudKitTests { ) ] ), - [4]: RecordType( + [5]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -195,147 +215,148 @@ extension BaseCloudKitTests { ) ] ), - [5]: RecordType( + [6]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "reminderID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "tagID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [6]: RecordType( + [7]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [7]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [8]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [9]: RecordType( + [10]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL DEFAULT 0 + REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "\'00000000-0000-0000-0000-000000000000\'", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "parentID", - notNull: false, - type: "TEXT" + notNull: true, + type: "INTEGER" ) ] ), - [10]: RecordType( + [11]: RecordType( tableName: "localUsers", schema: """ CREATE TABLE "localUsers" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE + "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -349,15 +370,15 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [11]: RecordType( + [12]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "count" INTEGER NOT NULL ) """, @@ -370,30 +391,30 @@ extension BaseCloudKitTests { type: "INTEGER" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [12]: RecordType( + [13]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, @@ -411,22 +432,22 @@ extension BaseCloudKitTests { ) ] ), - [13]: RecordType( + [14]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 19881854..8de88953 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -21,17 +21,17 @@ extension BaseCloudKitTests { tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -43,12 +43,32 @@ extension BaseCloudKitTests { ] ), [1]: RecordType( + tableName: "sqlite_sequence", + schema: "CREATE TABLE sqlite_sequence(name,seq)", + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "name", + notNull: false, + type: "" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "seq", + notNull: false, + type: "" + ) + ] + ), + [2]: RecordType( tableName: "remindersListAssets", schema: """ CREATE TABLE "remindersListAssets" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "coverImage" BLOB NOT NULL, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ @@ -60,37 +80,37 @@ extension BaseCloudKitTests { type: "BLOB" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [2]: RecordType( + [3]: RecordType( tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", @@ -104,20 +124,20 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [3]: RecordType( + [4]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -131,11 +151,11 @@ extension BaseCloudKitTests { type: "TEXT" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: "0", @@ -156,7 +176,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [5]: TableInfo( defaultValue: "\'\'", @@ -167,21 +187,21 @@ extension BaseCloudKitTests { ) ] ), - [4]: RecordType( + [5]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -192,147 +212,148 @@ extension BaseCloudKitTests { ) ] ), - [5]: RecordType( + [6]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "reminderID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "tagID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [6]: RecordType( + [7]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [7]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [8]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [9]: RecordType( + [10]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL DEFAULT 0 + REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "\'00000000-0000-0000-0000-000000000000\'", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "parentID", - notNull: false, - type: "TEXT" + notNull: true, + type: "INTEGER" ) ] ), - [10]: RecordType( + [11]: RecordType( tableName: "localUsers", schema: """ CREATE TABLE "localUsers" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE + "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -346,15 +367,15 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [11]: RecordType( + [12]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "count" INTEGER NOT NULL ) """, @@ -367,30 +388,30 @@ extension BaseCloudKitTests { type: "INTEGER" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [12]: RecordType( + [13]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, @@ -408,22 +429,22 @@ extension BaseCloudKitTests { ) ] ), - [13]: RecordType( + [14]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, @@ -495,12 +516,12 @@ extension BaseCloudKitTests { tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, + "remindersListID" INTEGER NOT NULL, "newFeature" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -514,11 +535,11 @@ extension BaseCloudKitTests { type: "TEXT" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: "0", @@ -546,7 +567,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [6]: TableInfo( defaultValue: "\'\'", diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index 88f6adb8..42744c8d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -155,5 +155,92 @@ extension BaseCloudKitTests { }() } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test( + """ + * The local client moves child to parent. + * The remote client deletes parent. + * Local client sets parent relationship to default value. + """ + ) func moveChildToParent_RemoteDeletesParent_CascadeSetDefault() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Parent(id: 0) + Parent(id: 1) + Parent(id: 2) + ChildWithOnDeleteSetDefault(id: 1, parentID: 1) + } + } + await syncEngine.processBatch() + + let modifications = { + syncEngine.modifyRecords(scope: .private, deleting: [Parent.recordID(for: 2)]) + }() + try withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try userDatabase.userWrite { db in + try ChildWithOnDeleteSetDefault.find(1).update { $0.parentID = 2 }.execute(db) + } + } + try await withDependencies { + $0.date.now.addTimeInterval(2) + } operation: { + await syncEngine.processBatch() + await modifications() + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteSetDefaults/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "childWithOnDeleteSetDefaults", + parent: CKReference(recordID: CKRecord.ID(0:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + parentID: 0 + ), + [1]: CKRecord( + recordID: CKRecord.ID(0:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 0 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + try { + try userDatabase.read { db in + try #expect( + ChildWithOnDeleteSetDefault.all.fetchAll(db) == [ + ChildWithOnDeleteSetDefault(id: 1, parentID: 0) + ] + ) + try #expect( + Parent.all.fetchAll(db) == [Parent(id: 0), Parent(id: 1)] + ) + } + }() + } + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index d57d6a22..ba55e9eb 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -489,7 +489,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "parents" FOR EACH ROW BEGIN UPDATE "childWithOnDeleteSetDefaults" - SET "parentID" = NULL + SET "parentID" = 0 WHERE "parentID" = "old"."id"; END """, @@ -498,7 +498,7 @@ extension BaseCloudKitTests { AFTER UPDATE ON "parents" FOR EACH ROW BEGIN UPDATE "childWithOnDeleteSetDefaults" - SET "parentID" = NULL + SET "parentID" = 0 WHERE "parentID" = "old"."id"; END """, diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index ac045f5b..013f3087 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -87,7 +87,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ @@ -96,9 +96,9 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersListAssets" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "coverImage" BLOB NOT NULL, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -106,9 +106,9 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersListPrivates" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -116,12 +116,12 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -131,7 +131,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ @@ -140,60 +140,61 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "reminderTags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT """ ) .execute(db) try #sql(""" CREATE TABLE "parents"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL ) STRICT """) .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteRestricts"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT """) .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteSetNulls"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """) .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL DEFAULT 0 + REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """) .execute(db) try #sql( """ CREATE TABLE "localUsers" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE + "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE ) STRICT """ ) .execute(db) try #sql(""" CREATE TABLE "modelAs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "count" INTEGER NOT NULL ) """) .execute(db) try #sql(""" CREATE TABLE "modelBs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) @@ -201,7 +202,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { .execute(db) try #sql(""" CREATE TABLE "modelCs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) From 1999c2d8390cb81e0ab8f5f6c7e3f1b3abc284d9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 12:12:39 -0700 Subject: [PATCH 390/581] add test --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 9 ++-- .../SyncEngineValidationTests.swift | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 7f0bf1ca..6caa4ff8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1419,9 +1419,10 @@ for (tableName, foreignKeys) in foreignKeysByTableName { if foreignKeys.count == 1, - [.restrict, .noAction].contains(foreignKeys[0].onDelete) + let foreignKey = foreignKeys.first, + [.restrict, .noAction].contains(foreignKey.onDelete) { - + throw InvalidParentForeignKey(tableName: tableName, foreignKey: foreignKey) } } @@ -1473,8 +1474,8 @@ public struct InvalidTableName: LocalizedError { let foreignKey: ForeignKey public var localizedDescription: String { """ - Foreign key \(tableName.debugDescription).\(foreignKey.from) action not supported. Must - be 'CASCADE', 'SET DEFAULT' or 'SET NULL'. + Foreign key \(tableName.debugDescription).\(foreignKey.from.debugDescription) action not \ + supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'. """ } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 8e843122..2f80f394 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -41,6 +41,52 @@ extension BaseCloudKitTests { ) } + @Test func foreignKeyActionValidation() async throws { + let error = try #require( + await #expect(throws: InvalidParentForeignKey.self) { + var configuration = Configuration() + configuration.foreignKeysEnabled = false + let database = try DatabaseQueue(configuration: configuration) + try await database.write { db in + try #sql( + """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "children" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE NO ACTION + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + metadatabaseURL: URL.temporaryDirectory.appending(path: UUID().uuidString), + tables: [] + ) + } + ) + #expect( + error.localizedDescription == + """ + Foreign key "children"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' \ + or 'SET NULL'. + """ + ) + } + @Test func userTriggerValidation() async throws { let error = try await #require( #expect(throws: InvalidUserTriggers.self) { From 72fdc5af779eca215c60c1b56b4d6e3adfea6691 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 12:13:04 -0700 Subject: [PATCH 391/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6caa4ff8..aa9519ab 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -958,7 +958,6 @@ try open(table) } - case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled, .batchRequestFailed, .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, From 800b53b908205838dcc5821d2cf495e3bd2ca324 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 16 Jul 2025 12:17:23 -0700 Subject: [PATCH 392/581] wip --- .../SharingGRDBCore/CloudKit/ForeignKey.swift | 339 +++--------------- 1 file changed, 50 insertions(+), 289 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift index 9b16bfd2..3eb1e213 100644 --- a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift +++ b/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift @@ -1,301 +1,62 @@ #if canImport(CloudKit) -import Foundation -import StructuredQueriesCore - -struct ForeignKey: QueryDecodable, QueryRepresentable { - typealias QueryValue = Self - - let table: String - let from: String - let to: String - let onUpdate: Action - let onDelete: Action - let notnull: Bool - - init(decoder: inout some QueryDecoder) throws { - guard - let table = try decoder.decode(String.self), - let from = try decoder.decode(String.self), - let to = try decoder.decode(String.self), - let onUpdate = try decoder.decode(Action.self), - let onDelete = try decoder.decode(Action.self), - let notnull = try decoder.decode(Bool.self) - else { - throw QueryDecodingError.missingRequiredColumn + import Foundation + import StructuredQueriesCore + + struct ForeignKey: QueryDecodable, QueryRepresentable { + typealias QueryValue = Self + + let table: String + let from: String + let to: String + let onUpdate: Action + let onDelete: Action + let notnull: Bool + + init(decoder: inout some QueryDecoder) throws { + guard + let table = try decoder.decode(String.self), + let from = try decoder.decode(String.self), + let to = try decoder.decode(String.self), + let onUpdate = try decoder.decode(Action.self), + let onDelete = try decoder.decode(Action.self), + let notnull = try decoder.decode(Bool.self) + else { + throw QueryDecodingError.missingRequiredColumn + } + self.table = table + self.from = from + self.to = to + self.onUpdate = onUpdate + self.onDelete = onDelete + self.notnull = notnull } - self.table = table - self.from = from - self.to = to - self.onUpdate = onUpdate - self.onDelete = onDelete - self.notnull = notnull - } - - enum Action: String, QueryBindable { - case cascade = "CASCADE" - case restrict = "RESTRICT" - case setDefault = "SET DEFAULT" - case setNull = "SET NULL" - case noAction = "NO ACTION" - } - - static func all( - _ tableName: String - ) -> some StructuredQueriesCore.Statement { - SQLQueryExpression( - """ - SELECT \(ForeignKey.columns) - FROM pragma_foreign_key_list(\(bind: tableName)) AS "foreign_keys" - JOIN pragma_table_info(\(bind: tableName)) AS "table_info" - ON "foreign_keys"."from" = "table_info"."name" - """, - as: ForeignKey.self - ) - } - - static var columns: QueryFragment { - """ - "table", "from", "to", "on_update", "on_delete", "notnull" - """ - } - - func createTriggers( - _ childTableName: String, - belongsTo parentTableName: String, - db: Database - ) throws { - switch onDelete { - case .cascade: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteCascade" - AFTER DELETE ON \(quote: parentTableName) - FOR EACH ROW BEGIN - DELETE FROM \(quote: childTableName) - WHERE \(quote: from) = "old".\(quote: to); - END - """ - ) - .execute(db) - - case .restrict: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteRestrict" - BEFORE DELETE ON \(quote: parentTableName) - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM \(quote: childTableName) - WHERE \(quote: from) = "old".\(quote: to); - END - """ - ) - .execute(db) - - case .setDefault: - let defaultValue = - try SQLQueryExpression( - """ - SELECT "dflt_value" - FROM pragma_table_info(\(bind: childTableName)) - WHERE "name" = \(bind: from) - """, - as: String?.self - ) - .fetchOne(db) ?? nil - - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteSetDefault" - AFTER DELETE ON \(quote: parentTableName) - FOR EACH ROW BEGIN - UPDATE \(quote: childTableName) - SET \(quote: from) = \(raw: defaultValue ?? "NULL") - WHERE \(quote: from) = "old".\(quote: to); - END - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onDeleteSetNull" - AFTER DELETE ON \(quote: parentTableName) - FOR EACH ROW BEGIN - UPDATE \(quote: childTableName) - SET \(quote: from) = NULL - WHERE \(quote: from) = "old".\(quote: to); - END - """ - ) - .execute(db) - case .noAction: - break - } - - switch onUpdate { - case .cascade: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateCascade" - AFTER UPDATE ON \(quote: parentTableName) - FOR EACH ROW BEGIN - UPDATE \(quote: childTableName) - SET \(quote: from) = "new".\(quote: to) - WHERE \(quote: from) = "old".\(quote: to); - END - """ - ) - .execute(db) - - case .restrict: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateRestrict" - BEFORE UPDATE ON \(quote: parentTableName) - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed') - FROM \(quote: childTableName) - WHERE \(quote: from) = "old".\(quote: to); - END - """ - ) - .execute(db) - - case .setDefault: - let defaultValue = - try SQLQueryExpression( - """ - SELECT "dflt_value" - FROM pragma_table_info(\(bind: childTableName)) - WHERE "name" = \(bind: from) - """, - as: String?.self - ) - .fetchOne(db) ?? nil - - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateSetDefault" - AFTER UPDATE ON \(quote: parentTableName) - FOR EACH ROW BEGIN - UPDATE \(quote: childTableName) - SET \(quote: from) = \(raw: defaultValue ?? "NULL") - WHERE \(quote: from) = "old".\(quote: to); - END - """ - ) - .execute(db) - case .setNull: - try SQLQueryExpression( - """ - CREATE TEMPORARY TRIGGER IF NOT EXISTS - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: parentTableName)_onUpdateSetNull" - AFTER UPDATE ON \(quote: parentTableName) - FOR EACH ROW BEGIN - UPDATE \(quote: childTableName) - SET \(quote: from) = NULL - WHERE \(quote: from) = "old".\(quote: to); - END - """ - ) - .execute(db) - case .noAction: - break + enum Action: String, QueryBindable { + case cascade = "CASCADE" + case restrict = "RESTRICT" + case setDefault = "SET DEFAULT" + case setNull = "SET NULL" + case noAction = "NO ACTION" } - } - - func dropTriggers(for childTableName: String, db: Database) throws { - switch onDelete { - case .cascade: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onDeleteCascade" - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onDeleteSetNull" - """ - ) - .execute(db) - case .setDefault: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onDeleteSetDefault" - """ - ) - .execute(db) - - case .restrict: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onDeleteRestrict" + static func all( + _ tableName: String + ) -> some StructuredQueriesCore.Statement { + SQLQueryExpression( """ + SELECT \(ForeignKey.columns) + FROM pragma_foreign_key_list(\(bind: tableName)) AS "foreign_keys" + JOIN pragma_table_info(\(bind: tableName)) AS "table_info" + ON "foreign_keys"."from" = "table_info"."name" + """, + as: ForeignKey.self ) - .execute(db) - - case .noAction: - break } - switch onUpdate { - case .cascade: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onUpdateCascade" - """ - ) - .execute(db) - - case .setNull: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onUpdateSetNull" - """ - ) - .execute(db) - - case .setDefault: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onUpdateSetDefault" - """ - ) - .execute(db) - - case .restrict: - try SQLQueryExpression( - """ - DROP TRIGGER - "\(raw: .sqliteDataCloudKitSchemaName)_\(raw: childTableName)_belongsTo_\(raw: table)_onUpdateRestrict" - """ - ) - .execute(db) - - case .noAction: - break + static var columns: QueryFragment { + """ + "table", "from", "to", "on_update", "on_delete", "notnull" + """ } } -} #endif From 5d8fbcfc625a7af49b1e0dda019aef565d3262f0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 12:34:19 -0700 Subject: [PATCH 393/581] format/ --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index aa9519ab..dbfe5151 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -929,23 +929,29 @@ case .setDefault: guard let recordType = try RecordType.find(table.tableName).fetchOne(db), - let columnInfo = recordType.tableInfo.first(where: { $0.name == foreignKey.from }) + let columnInfo = recordType.tableInfo.first(where: { + $0.name == foreignKey.from + }) else { return } let defaultValue = columnInfo.defaultValue ?? "NULL" - try SQLQueryExpression(""" + try SQLQueryExpression( + """ UPDATE \(T.self) SET \(quote: foreignKey.from, delimiter: .identifier) = (\(raw: defaultValue)) WHERE \(T.primaryKey) = \(bind: recordPrimaryKey) - """) + """ + ) .execute(db) break case .setNull: - try SQLQueryExpression(""" + try SQLQueryExpression( + """ UPDATE \(T.self) SET \(quote: foreignKey.from, delimiter: .identifier) = NULL WHERE \(T.primaryKey) = \(bind: recordPrimaryKey) - """) - .execute(db) + """ + ) + .execute(db) case .noAction: reportIssue( "'NO ACTION' foreign key actions not supported for parent relationships." @@ -1178,7 +1184,8 @@ } return "\(quote: columnName) = \(data?.queryFragment ?? "NULL")" } else { - return "\(quote: columnName) = \(record.encryptedValues[columnName]?.queryFragment ?? "NULL")" + return + "\(quote: columnName) = \(record.encryptedValues[columnName]?.queryFragment ?? "NULL")" } } .joined(separator: ",") @@ -1416,8 +1423,7 @@ } for (tableName, foreignKeys) in foreignKeysByTableName { - if - foreignKeys.count == 1, + if foreignKeys.count == 1, let foreignKey = foreignKeys.first, [.restrict, .noAction].contains(foreignKey.onDelete) { @@ -1457,15 +1463,15 @@ } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -public struct InvalidTableName: LocalizedError { - let tableName: String - public var localizedDescription: String { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct InvalidTableName: LocalizedError { + let tableName: String + public var localizedDescription: String { """ Table name \(tableName.debugDescription) contains invalid character ':'. """ + } } -} @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public struct InvalidParentForeignKey: LocalizedError { From ff131f42e9985a9c662f73b0b52f5e61fbe7102f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 14:02:28 -0700 Subject: [PATCH 394/581] wip --- .../CloudKitPlaygroundApp.swift | 44 ++++- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 37 +++- .../CloudKitTests/AssetsTests.swift | 1 - .../CloudKitTests/CloudKitTests.swift | 2 - .../FetchRecordZoneChangesTests.swift | 2 - .../CloudKitTests/ForeignKeyTests.swift | 184 ------------------ .../CloudKitTests/MergeConflictTests.swift | 2 - .../MockCloudDatabaseTests.swift | 2 - .../ReferenceViolationTests.swift | 88 +++++++++ .../CloudKitTests/SchemaChangeTests.swift | 1 - .../CloudKitTests/SharingTests.swift | 2 - .../Internal/BaseCloudKitTests.swift | 3 +- .../Internal/CloudKitTestHelpers.swift | 3 +- Tests/SharingGRDBTests/Internal/Schema.swift | 12 -- 14 files changed, 163 insertions(+), 220 deletions(-) diff --git a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift index 1f90aeb0..acafc7b7 100644 --- a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift +++ b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift @@ -8,16 +8,54 @@ struct CloudKitPlaygroundApp: App { @UIApplicationDelegateAdaptor var delegate: AppDelegate init() { + let container = CKContainer( + identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" + ) prepareDependencies { $0.defaultDatabase = try! appDatabase() $0.defaultSyncEngine = try! SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" - ), + container: container, database: $0.defaultDatabase, tables: [ModelA.self, ModelB.self, ModelC.self] ) } + Task { + let parentRecord = CKRecord(recordType: "parent1", recordID: CKRecord.ID(recordName: "1")) + let childRecord = CKRecord(recordType: "child", recordID: CKRecord.ID(recordName: "2")) + childRecord.parent = CKRecord.Reference.init(record: parentRecord, action: .none) + let childRecord2 = CKRecord(recordType: "grandchild", recordID: CKRecord.ID(recordName: "3")) + childRecord2.parent = CKRecord.Reference.init(record: parentRecord, action: .none) + + _ = try await container.privateCloudDatabase + .modifyRecords(saving: [], deleting: [parentRecord.recordID, childRecord.recordID, childRecord2.recordID]) + + _ = try await container.privateCloudDatabase.modifyRecords(saving: [parentRecord, childRecord], deleting: []) + + + print("!!!") + do { + let (saveResults, deleteResults) = try await container.privateCloudDatabase + .modifyRecords( + saving: [], + deleting: [parentRecord.recordID] + ) + print(saveResults) + print(deleteResults) + for (recordID, result) in deleteResults { + do { + try result.get() + } catch let error as CKError { + print("!!") + } catch { + print("!!") + } + } + print("") + } catch { + print(error) + } + + } } var body: some Scene { WindowGroup { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index dbfe5151..dbb70e85 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -863,6 +863,8 @@ failedRecordDeletes: [CKRecord.ID: CKError] = [:], syncEngine: any SyncEngineProtocol ) async { + print("!!!") + for savedRecord in savedRecords { await refreshLastKnownServerRecord(savedRecord) } @@ -873,8 +875,7 @@ syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } - for failedRecordSave in failedRecordSaves { - let failedRecord = failedRecordSave.record + for (failedRecord, error) in failedRecordSaves { func clearServerRecord() { withErrorReporting { try userDatabase.write { db in @@ -886,9 +887,9 @@ } } - switch failedRecordSave.error.code { + switch error.code { case .serverRecordChanged: - guard let serverRecord = failedRecordSave.error.serverRecord else { continue } + guard let serverRecord = error.serverRecord else { continue } upsertFromServerRecord(serverRecord) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) @@ -979,9 +980,31 @@ } // TODO: handle event.failedRecordDeletes ? look at apple sample code - if !failedRecordDeletes.isEmpty { - print("!!!!") - } + /* + + */ + + for (failedRecordID, error) in failedRecordDeletes { + guard + error.code == .referenceViolation + else { continue } + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + // insert into unsycned records + } + } +// //error.clientRecord +// switch error.code { +// case .referenceViolation: +// CKFetchRecordsOperation() +// syncEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) +// print("!!!") +// break +// default: +// continue +// } + } + // if something was added to UnsyncedRecordIDs, process it } private func cacheShare(_ share: CKShare) async throws { diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index 16c3ce05..7d765add 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -10,7 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class AssetsTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now @Dependency(\.dataManager) var dataManager var inMemoryDataManager: InMemoryDataManager { dataManager as! InMemoryDataManager diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 68c6685e..e30f0c51 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -10,8 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() throws { let zones = try userDatabase.userRead { db in diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 5a7ab42a..ebc9312a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -11,8 +11,6 @@ extension BaseCloudKitTests { @MainActor @Suite final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @Test func saveExtraFieldsToSyncMetadata() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift index 5f16e2c2..65e151d1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift @@ -302,190 +302,6 @@ extension BaseCloudKitTests { } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteRestrict() async throws { - try await userDatabase.userWrite { db in - try db.seed { - Parent(id: 1) - ChildWithOnDeleteRestrict(id: 1, parentID: 1) - } - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteRestricts", - parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - parentID: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - let error = #expect(throws: DatabaseError.self) { - try withDependencies { - $0.date.now.addTimeInterval(60) - } operation: { - try self.userDatabase.userWrite { db in - try Parent.find(1).delete().execute(db) - } - } - } - #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try await userDatabase.userRead { db in - try expectNoDifference( - ChildWithOnDeleteRestrict.all.fetchAll(db), - [ - ChildWithOnDeleteRestrict(id: 1, parentID: 1) - ] - ) - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteRestricts", - parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - parentID: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func updateRestrict() async throws { - try await userDatabase.userWrite { db in - try db.seed { - Parent(id: 1) - ChildWithOnDeleteRestrict(id: 1, parentID: 1) - } - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteRestricts", - parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - parentID: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - let error = #expect(throws: DatabaseError.self) { - try withDependencies { - $0.date.now.addTimeInterval(60) - } operation: { - try self.userDatabase.userWrite { db in - try Parent.find(1).update { $0.id = 2 }.execute(db) - } - } - } - #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try await userDatabase.userRead { db in - try expectNoDifference( - ChildWithOnDeleteRestrict.all.fetchAll(db), - [ - ChildWithOnDeleteRestrict(id: 1, parentID: 1) - ] - ) - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteRestricts", - parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - parentID: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func nonSyncTable() async throws { try await userDatabase.userWrite { db in diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index cb9fd0c7..0635afe6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -10,8 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index 67f83f25..6e4e1a90 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -10,8 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class MockCloudDatabaseTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func saveTransaction_ChildBeforeParent() async throws { let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index 42744c8d..7273f216 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -76,6 +76,94 @@ extension BaseCloudKitTests { }() } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test( + """ + * The local client deletes a list. + * The remote client adds reminder to that list. + """ + ) func deleteList_RemoteAddsReminderToList() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + await syncEngine.processBatch() + + try withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try userDatabase.userWrite { db in + try Reminder.insert { + Reminder(id: 2, title: "Take walk", remindersListID: 1) + } + .execute(db) + try RemindersList.find(1).delete().execute(db) + } + } + let modifications = withDependencies { + $0.date.now.addTimeInterval(2) + } operation: { + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + return { + syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + }() + } + await modifications() + await syncEngine.processBatch() + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try userDatabase.read { db in + try #expect(Reminder.count().fetchOne(db) == 0) + try #expect(RemindersList.count().fetchOne(db) == 0) + } + }() + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test( """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index c728a917..ee35ae13 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -9,7 +9,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class SchemaChangeTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now @Dependency(\.dataManager) var dataManager var inMemoryDataManager: InMemoryDataManager { dataManager as! InMemoryDataManager diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 1f5319d5..6d525239 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -10,8 +10,6 @@ import Testing extension BaseCloudKitTests { @MainActor final class SharingTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.date.now) var now - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareNonRootRecord() async throws { let reminder = Reminder(id: 1, title: "Groceries", remindersListID: 1) diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 4889d3d4..c05ed2d0 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -17,6 +17,8 @@ class BaseCloudKitTests: @unchecked Sendable { let userDatabase: UserDatabase private let _syncEngine: any Sendable + @Dependency(\.date.now) var now + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var syncEngine: SyncEngine { _syncEngine as! SyncEngine @@ -51,7 +53,6 @@ class BaseCloudKitTests: @unchecked Sendable { Tag.self, ReminderTag.self, Parent.self, - ChildWithOnDeleteRestrict.self, ChildWithOnDeleteSetNull.self, ChildWithOnDeleteSetDefault.self, ModelA.self, diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 1f50fa39..c68035dc 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -354,7 +354,8 @@ final class MockCloudDatabase: CloudDatabase { // giving it a new identity, rather than leveraging an existing CKRecord. Issue.record( """ - A new identity was created for an existing 'CKRecord'. Rather than creating \ + A new identity was created for an existing 'CKRecord' \ + ('\(existingRecord.recordID.recordName)'). Rather than creating \ 'CKRecord' from scratch for an existing record, use the database to fetch the \ current record. """ diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 013f3087..8e68b7ac 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -35,14 +35,9 @@ import SharingGRDB var reminderID: Reminder.ID var tagID: Tag.ID } - @Table struct Parent: Equatable, Identifiable { let id: Int } -@Table struct ChildWithOnDeleteRestrict: Equatable, Identifiable { - let id: Int - let parentID: Parent.ID -} @Table struct ChildWithOnDeleteSetNull: Equatable, Identifiable { let id: Int let parentID: Parent.ID? @@ -153,13 +148,6 @@ func database(containerIdentifier: String) throws -> DatabasePool { ) STRICT """) .execute(db) - try #sql(""" - CREATE TABLE "childWithOnDeleteRestricts"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT - ) STRICT - """) - .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteSetNulls"( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, From 1dd92aa614d323765ccf7104e74106647b39d0d5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 14:17:35 -0700 Subject: [PATCH 395/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 6 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 52 +- .../ForeignKeyConstraintTests.swift | 99 ---- .../CloudKitTests/ForeignKeyTests.swift | 510 ------------------ .../CloudKitTests/SharingTests.swift | 3 +- 5 files changed, 21 insertions(+), 649 deletions(-) delete mode 100644 Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 385522a7..5e47bcdf 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -231,7 +231,8 @@ extension CKRecord { func update( with other: CKRecord, row: T, - columnNames: inout [String] + columnNames: inout [String], + parentForeignKey: ForeignKey? ) { typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable @@ -274,6 +275,9 @@ extension CKRecord { } if didSet || isRowValueModified { columnNames.removeAll(where: { $0 == key }) + if didSet, let parentForeignKey, key == parentForeignKey.from { + self.parent = other.parent + } } } open(column) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 5c28a258..da095453 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -778,7 +778,11 @@ .map(CKRecord.ID.init(unsyncedRecordID:)) ) } + let count = unsyncedRecordIDs.count unsyncedRecordIDs.subtract(modifications.map(\.recordID)) + if count != unsyncedRecordIDs.count { + print("!!!!") + } let results = try await syncEngine.database.records(for: Array(unsyncedRecordIDs)) var unsyncedRecords: [CKRecord] = [] for (recordID, result) in results { @@ -851,17 +855,13 @@ if let table = tablesByName[recordType] { func open(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in -// try SyncEngine.$_isUpdatingRecord.withValue(false) { - try T - .where { - SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") - } - .delete() - .execute(db) -// } - print("!!!") + try T + .where { + SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") + } + .delete() + .execute(db) } } } @@ -925,33 +925,8 @@ case .serverRejectedRequest: clearServerRecord() - case .referenceViolation: - // TODO: look up FK for parent relationship, if "DELETE CASCADE" then delete, else set NULL - //reportIssue("Reference violation") - await withErrorReporting { - try await userDatabase.write { db in - guard - let table = self.tablesByName[failedRecord.recordType], - let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey - else { return } - func open(_: T.Type) throws { - try Self.$_isUpdatingRecord.withValue(false) { - let q = try T - .where { SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") } - .delete() - print("!!!") - try q - .execute(db) - } - } - try open(table) - } - } - print("!!!") - break - case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, - .notAuthenticated, .operationCancelled, .batchRequestFailed, + .notAuthenticated, .referenceViolation, .operationCancelled, .batchRequestFailed, .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, .permissionFailure, .invalidArguments, .resultsTruncated, .assetFileNotFound, .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired, @@ -1065,7 +1040,10 @@ serverRecord.update( with: allFields, row: T(queryOutput: row), - columnNames: &columnNames + columnNames: &columnNames, + parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1 + ? foreignKeysByTableName[T.tableName]?.first + : nil ) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index f8f3d6bc..5ea8b375 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -299,105 +299,6 @@ extension BaseCloudKitTests { }() } - @Test( - """ - * The local client moves a reminder to a list. - * The remote client deletes that list. - """ - ) func moveReminderToList_RemoteDeletesList() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } - } - await syncEngine.processBatch() - - let modifications = { - syncEngine.modifyRecords(scope: .private, deleting: [RemindersList.recordID(for: 2)]) - }() - try withDependencies { - $0.date.now.addTimeInterval(1) - } operation: { - try userDatabase.userWrite { db in - try Reminder.find(1).update { $0.remindersListID = 2 }.execute(db) - } - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - await modifications() - await syncEngine.processBatch() - - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try { - try userDatabase.read { db in - try #expect(Reminder.count().fetchOne(db) == 0) - try #expect( - RemindersList.all.fetchAll(db) == [ - RemindersList(id: 1, title: "Personal") - ] - ) - } - }() - } - @Test( """ Remote changes parent relationship to an unknown record which is synchronized later. diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift deleted file mode 100644 index 4c6262a1..00000000 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyTests.swift +++ /dev/null @@ -1,510 +0,0 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import OrderedCollections -import SharingGRDB -import SnapshotTestingCustomDump -import Testing - -extension BaseCloudKitTests { - @MainActor - final class ForeignKeyTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteCascade() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Groceries", remindersListID: 1) - Reminder(id: 2, title: "Walk", remindersListID: 1) - } - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Groceries" - ), - [1]: CKRecord( - recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 2, - isCompleted: 0, - remindersListID: 1, - title: "Walk" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await withDependencies { - $0.date.now.addTimeInterval(60) - } operation: { - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } - } - try { - try userDatabase.userRead { db in - try #expect(Reminder.all.fetchAll(db) == []) - } - }() - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteSetNull() async throws { - try await userDatabase.userWrite { db in - try db.seed { - Parent(id: 1) - ChildWithOnDeleteSetNull(id: 1, parentID: 1) - } - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteSetNulls", - parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - parentID: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await withDependencies { - $0.date.now.addTimeInterval(60) - } operation: { - try await userDatabase.userWrite { db in - try Parent.find(1).delete().execute(db) - } - } - try await userDatabase.userRead { db in - try expectNoDifference( - ChildWithOnDeleteSetNull.all.fetchAll(db), - [ - ChildWithOnDeleteSetNull(id: 1, parentID: nil) - ] - ) - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteSetNulls", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func updateCascade() 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: "Walk", remindersListID: 1) - } - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 2, - isCompleted: 0, - remindersListID: 1, - title: "Groceries" - ), - [1]: CKRecord( - recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 3, - isCompleted: 0, - remindersListID: 1, - title: "Walk" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await withDependencies { - $0.date.now.addTimeInterval(60) - } operation: { - try await userDatabase.userWrite { db in - try RemindersList.find(1).update { $0.id = 9 }.execute(db) - } - } - try await userDatabase.userRead { db in - try expectNoDifference( - Reminder.all.fetchAll(db), - [ - Reminder(id: 2, title: "Groceries", remindersListID: 9), - Reminder(id: 3, title: "Walk", remindersListID: 9), - Reminder(id: 4, title: "Haircut", remindersListID: 9), - ] - ) - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 2, - isCompleted: 0, - remindersListID: 9, - title: "Groceries" - ), - [1]: CKRecord( - recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 3, - isCompleted: 0, - remindersListID: 9, - title: "Walk" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ), - [3]: CKRecord( - recordID: CKRecord.ID(9:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 9, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteRestrict() async throws { - try await userDatabase.userWrite { db in - try db.seed { - Parent(id: 1) - ChildWithOnDeleteRestrict(id: 1, parentID: 1) - } - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteRestricts", - parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - parentID: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - let error = #expect(throws: DatabaseError.self) { - try withDependencies { - $0.date.now.addTimeInterval(60) - } operation: { - try self.userDatabase.userWrite { db in - try Parent.find(1).delete().execute(db) - } - } - } - #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try await userDatabase.userRead { db in - try expectNoDifference( - ChildWithOnDeleteRestrict.all.fetchAll(db), - [ - ChildWithOnDeleteRestrict(id: 1, parentID: 1) - ] - ) - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteRestricts", - parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - parentID: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func updateRestrict() async throws { - try await userDatabase.userWrite { db in - try db.seed { - Parent(id: 1) - ChildWithOnDeleteRestrict(id: 1, parentID: 1) - } - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteRestricts", - parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - parentID: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - let error = #expect(throws: DatabaseError.self) { - try withDependencies { - $0.date.now.addTimeInterval(60) - } operation: { - try self.userDatabase.userWrite { db in - try Parent.find(1).update { $0.id = 2 }.execute(db) - } - } - } - #expect(try #require(error).localizedDescription.contains("FOREIGN KEY constraint failed")) - try await userDatabase.userRead { db in - try expectNoDifference( - ChildWithOnDeleteRestrict.all.fetchAll(db), - [ - ChildWithOnDeleteRestrict(id: 1, parentID: 1) - ] - ) - } - - await syncEngine.processBatch() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteRestricts/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteRestricts", - parent: CKReference(recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - parentID: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func nonSyncTable() async throws { - try await userDatabase.userWrite { db in - try db.seed { - LocalUser(id: 1, name: "Blob", parentID: nil) - LocalUser(id: 2, name: "Blob Jr", parentID: 1) - } - } - try await self.userDatabase.userWrite { db in - try LocalUser.find(1).delete().execute(db) - } - try await userDatabase.userRead { db in - try expectNoDifference( - LocalUser.all.fetchAll(db), - [] - ) - } - } - } -} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index d9eed997..a241a6f0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -136,7 +136,7 @@ extension BaseCloudKitTests { remindersListRecord.setValue(false, forKey: "isCompleted", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) - let share = CKShare.init( + let share = CKShare( rootRecord: remindersListRecord, shareID: CKRecord.ID( recordName: "Share-\(UUID(1).uuidString.lowercased())", @@ -147,7 +147,6 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords(scope: .shared, saving: [share]) await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]) - await syncEngine.processBatch() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ MockCloudContainer( From a0d9abaa626eb1c07447d426c49926f4d20483d2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 14:20:20 -0700 Subject: [PATCH 396/581] fix tests --- .../CloudKitTests/SharingTests.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index cb93c4aa..d974cfee 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -132,16 +132,16 @@ extension BaseCloudKitTests { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: UUID(1), zoneID: externalZoneID) + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) - remindersListRecord.setValue(UUID(1).uuidString.lowercased(), forKey: "id", at: now) + remindersListRecord.setValue(1, forKey: "id", at: now) remindersListRecord.setValue(false, forKey: "isCompleted", at: now) remindersListRecord.setValue("Personal", forKey: "title", at: now) let share = CKShare.init( rootRecord: remindersListRecord, shareID: CKRecord.ID( - recordName: "Share-\(UUID(1).uuidString.lowercased())", + recordName: "Share-\(1)", zoneID: externalZoneID ) ) @@ -161,7 +161,7 @@ extension BaseCloudKitTests { databaseScope: .shared, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/external.zone/external.owner), + recordID: CKRecord.ID(Share-1/external.zone/external.owner), recordType: "cloudkit.share", parent: nil, share: nil @@ -170,8 +170,8 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), - id: "00000000-0000-0000-0000-000000000001", + share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), + id: 1, isCompleted: 0, title: "Personal" ) @@ -188,9 +188,9 @@ extension BaseCloudKitTests { """ [ [0]: SyncMetadata( - recordPrimaryKey: "00000000-0000-0000-0000-000000000001", + recordPrimaryKey: "1", recordType: "remindersLists", - recordName: "00000000-0000-0000-0000-000000000001:remindersLists", + recordName: "1:remindersLists", parentRecordPrimaryKey: nil, parentRecordType: nil, parentRecordName: nil, @@ -198,14 +198,14 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-00000000-0000-0000-0000-000000000001/external.zone/external.owner)) + share: CKReference(recordID: CKRecord.ID(Share-1/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-00000000-0000-0000-0000-000000000001/external.zone/external.owner)), - id: "00000000-0000-0000-0000-000000000001", + share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), + id: 1, isCompleted: 0, title: "Personal" ), From 47c04baf63b4af4e864224cb5da98b43d3508113 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 14:23:33 -0700 Subject: [PATCH 397/581] fix tests --- .../CloudKitTests/CloudKitTests.swift | 164 +++++++++-------- .../CloudKitTests/RecordTypeTests.swift | 174 ++++++++++-------- Tests/SharingGRDBTests/Internal/Schema.swift | 46 ++--- 3 files changed, 212 insertions(+), 172 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 10d53d79..58558eec 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -24,17 +24,17 @@ extension BaseCloudKitTests { tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -46,12 +46,32 @@ extension BaseCloudKitTests { ] ), [1]: RecordType( + tableName: "sqlite_sequence", + schema: "CREATE TABLE sqlite_sequence(name,seq)", + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "name", + notNull: false, + type: "" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "seq", + notNull: false, + type: "" + ) + ] + ), + [2]: RecordType( tableName: "remindersListAssets", schema: """ CREATE TABLE "remindersListAssets" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "coverImage" BLOB NOT NULL, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ @@ -63,37 +83,37 @@ extension BaseCloudKitTests { type: "BLOB" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [2]: RecordType( + [3]: RecordType( tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", @@ -107,20 +127,20 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [3]: RecordType( + [4]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -134,11 +154,11 @@ extension BaseCloudKitTests { type: "TEXT" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: "0", @@ -159,7 +179,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [5]: TableInfo( defaultValue: "\'\'", @@ -170,21 +190,21 @@ extension BaseCloudKitTests { ) ] ), - [4]: RecordType( + [5]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -195,147 +215,147 @@ extension BaseCloudKitTests { ) ] ), - [5]: RecordType( + [6]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "reminderID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "tagID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [6]: RecordType( + [7]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [7]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [8]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [9]: RecordType( + [10]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "\'00000000-0000-0000-0000-000000000000\'", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [10]: RecordType( + [11]: RecordType( tableName: "localUsers", schema: """ CREATE TABLE "localUsers" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE + "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -349,15 +369,15 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [11]: RecordType( + [12]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "count" INTEGER NOT NULL ) """, @@ -370,30 +390,30 @@ extension BaseCloudKitTests { type: "INTEGER" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [12]: RecordType( + [13]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, @@ -411,22 +431,22 @@ extension BaseCloudKitTests { ) ] ), - [13]: RecordType( + [14]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 32bb3f95..6cbeaf59 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -21,17 +21,17 @@ extension BaseCloudKitTests { tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -43,12 +43,32 @@ extension BaseCloudKitTests { ] ), [1]: RecordType( + tableName: "sqlite_sequence", + schema: "CREATE TABLE sqlite_sequence(name,seq)", + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "name", + notNull: false, + type: "" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "seq", + notNull: false, + type: "" + ) + ] + ), + [2]: RecordType( tableName: "remindersListAssets", schema: """ CREATE TABLE "remindersListAssets" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "coverImage" BLOB NOT NULL, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ @@ -60,37 +80,37 @@ extension BaseCloudKitTests { type: "BLOB" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [2]: RecordType( + [3]: RecordType( tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", @@ -104,20 +124,20 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [3]: RecordType( + [4]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -131,11 +151,11 @@ extension BaseCloudKitTests { type: "TEXT" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: "0", @@ -156,7 +176,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [5]: TableInfo( defaultValue: "\'\'", @@ -167,21 +187,21 @@ extension BaseCloudKitTests { ) ] ), - [4]: RecordType( + [5]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -192,147 +212,147 @@ extension BaseCloudKitTests { ) ] ), - [5]: RecordType( + [6]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "reminderID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "tagID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [6]: RecordType( + [7]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [7]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteRestricts", schema: """ CREATE TABLE "childWithOnDeleteRestricts"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [8]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [9]: RecordType( + [10]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "\'00000000-0000-0000-0000-000000000000\'", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [10]: RecordType( + [11]: RecordType( tableName: "localUsers", schema: """ CREATE TABLE "localUsers" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE + "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE ) STRICT """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", @@ -346,15 +366,15 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "parentID", notNull: false, - type: "TEXT" + type: "INTEGER" ) ] ), - [11]: RecordType( + [12]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "count" INTEGER NOT NULL ) """, @@ -367,30 +387,30 @@ extension BaseCloudKitTests { type: "INTEGER" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ) ] ), - [12]: RecordType( + [13]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, @@ -408,22 +428,22 @@ extension BaseCloudKitTests { ) ] ), - [13]: RecordType( + [14]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """, tableInfo: [ [0]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, @@ -495,12 +515,12 @@ extension BaseCloudKitTests { tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, "newFeature" INTEGER NOT NULL, + "remindersListID" INTEGER NOT NULL, "newFeature" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -514,11 +534,11 @@ extension BaseCloudKitTests { type: "TEXT" ), [1]: TableInfo( - defaultValue: "uuid()", + defaultValue: nil, isPrimaryKey: true, name: "id", notNull: true, - type: "TEXT" + type: "INTEGER" ), [2]: TableInfo( defaultValue: "0", @@ -546,7 +566,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "remindersListID", notNull: true, - type: "TEXT" + type: "INTEGER" ), [6]: TableInfo( defaultValue: "\'\'", diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index d9b2cb4f..75a1be30 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -87,7 +87,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ @@ -96,9 +96,9 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersListAssets" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "coverImage" BLOB NOT NULL, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -106,9 +106,9 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "remindersListPrivates" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -116,12 +116,12 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "reminders" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" TEXT NOT NULL, + "remindersListID" INTEGER NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -131,7 +131,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ @@ -140,60 +140,60 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "reminderTags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT """ ) .execute(db) try #sql(""" CREATE TABLE "parents"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()) + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL ) STRICT """) .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteRestricts"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT ) STRICT """) .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteSetNulls"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """) .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT '00000000-0000-0000-0000-000000000000', - "parentID" TEXT REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT """) .execute(db) try #sql( """ CREATE TABLE "localUsers" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "parentID" TEXT REFERENCES "localUsers"("id") ON DELETE CASCADE + "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE ) STRICT """ ) .execute(db) try #sql(""" CREATE TABLE "modelAs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "count" INTEGER NOT NULL ) """) .execute(db) try #sql(""" CREATE TABLE "modelBs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) @@ -201,7 +201,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { .execute(db) try #sql(""" CREATE TABLE "modelCs" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) From a5a9a0b6f5451224c1ae05db81e385948d26cf68 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 16:32:28 -0700 Subject: [PATCH 398/581] add a test --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 14 +-- .../ForeignKeyConstraintTests.swift | 85 +++++++++++++++++++ .../CloudKitTests/SharingTests.swift | 18 +++- 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 33001b6a..99f21e8d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -769,7 +769,6 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { - let unsyncedRecords = await withErrorReporting(.sqliteDataCloudKitFailure) { var unsyncedRecordIDs = try await userDatabase.write { db in Set( @@ -778,10 +777,15 @@ .map(CKRecord.ID.init(unsyncedRecordID:)) ) } - let count = unsyncedRecordIDs.count - unsyncedRecordIDs.subtract(modifications.map(\.recordID)) - if count != unsyncedRecordIDs.count { - print("!!!!") + let modificationRecordIDs = Set(modifications.map(\.recordID)) + let unsyncedRecordIDsToDelete = modificationRecordIDs.intersection(unsyncedRecordIDs) + unsyncedRecordIDs.subtract(modificationRecordIDs) + if !unsyncedRecordIDsToDelete.isEmpty { + try await userDatabase.write { db in + for recordID in unsyncedRecordIDsToDelete { + try UnsyncedRecordID.find(recordID).delete().execute(db) + } + } } let results = try await syncEngine.database.records(for: Array(unsyncedRecordIDs)) var unsyncedRecords: [CKRecord] = [] diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index 5ea8b375..76a4f033 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -150,6 +150,91 @@ extension BaseCloudKitTests { }() } + @Test( + """ + 1) Receive child record without parent. + 2) Receive child record with parent + """ + ) func receiveChildRecordBeforeParent_ReceiveChildAndParentRecord() 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) + + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + + _ = { + syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + }() + await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + let freshReminderRecord = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + let freshRemindersListRecord = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + await syncEngine.modifyRecords( + scope: .private, + saving: [freshReminderRecord, freshRemindersListRecord] + ) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try 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) + ] + ) + } + }() + } + @Test func receiveChild_Relaunch_ReceiveParent() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index afcb874b..26966b85 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -146,6 +146,7 @@ extension BaseCloudKitTests { await syncEngine.modifyRecords(scope: .shared, saving: [share]) await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]) + await syncEngine.processBatch() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ @@ -191,8 +192,21 @@ extension BaseCloudKitTests { parentRecordPrimaryKey: nil, parentRecordType: nil, parentRecordName: nil, - lastKnownServerRecord: nil, - _lastKnownServerRecordAllFields: nil, + lastKnownServerRecord: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(Share-1/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/external.zone/external.owner)), + id: 1, + isCompleted: 0, + title: "Personal" + ), share: nil, isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) From 1b50271872e5d067e4f7c4a895388bd1c3dbed5e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 16:37:42 -0700 Subject: [PATCH 399/581] wip --- .../CloudKitPlaygroundApp.swift | 44 ++----------------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 7 --- 2 files changed, 3 insertions(+), 48 deletions(-) diff --git a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift index acafc7b7..1f90aeb0 100644 --- a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift +++ b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift @@ -8,54 +8,16 @@ struct CloudKitPlaygroundApp: App { @UIApplicationDelegateAdaptor var delegate: AppDelegate init() { - let container = CKContainer( - identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" - ) prepareDependencies { $0.defaultDatabase = try! appDatabase() $0.defaultSyncEngine = try! SyncEngine( - container: container, + container: CKContainer( + identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" + ), database: $0.defaultDatabase, tables: [ModelA.self, ModelB.self, ModelC.self] ) } - Task { - let parentRecord = CKRecord(recordType: "parent1", recordID: CKRecord.ID(recordName: "1")) - let childRecord = CKRecord(recordType: "child", recordID: CKRecord.ID(recordName: "2")) - childRecord.parent = CKRecord.Reference.init(record: parentRecord, action: .none) - let childRecord2 = CKRecord(recordType: "grandchild", recordID: CKRecord.ID(recordName: "3")) - childRecord2.parent = CKRecord.Reference.init(record: parentRecord, action: .none) - - _ = try await container.privateCloudDatabase - .modifyRecords(saving: [], deleting: [parentRecord.recordID, childRecord.recordID, childRecord2.recordID]) - - _ = try await container.privateCloudDatabase.modifyRecords(saving: [parentRecord, childRecord], deleting: []) - - - print("!!!") - do { - let (saveResults, deleteResults) = try await container.privateCloudDatabase - .modifyRecords( - saving: [], - deleting: [parentRecord.recordID] - ) - print(saveResults) - print(deleteResults) - for (recordID, result) in deleteResults { - do { - try result.get() - } catch let error as CKError { - print("!!") - } catch { - print("!!") - } - } - print("") - } catch { - print(error) - } - - } } var body: some Scene { WindowGroup { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 05ccc478..7109d945 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -112,13 +112,6 @@ foreignKeysByTableName: foreignKeysByTableName, userDatabase: userDatabase ) - // TODO: Explain why / link to documentation? - precondition( - !userDatabase.configuration.foreignKeysEnabled, - """ - Foreign key support must be disabled to synchronize with CloudKit. - """ - ) self.container = container self.defaultSyncEngines = defaultSyncEngines self.userDatabase = userDatabase From a54e329d5dc63d6147cda999f7c406c990613dee Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 16:58:31 -0700 Subject: [PATCH 400/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 45 ++++---- .../CloudKitTests/CloudKitTests.swift | 35 +----- .../CloudKitTests/RecordTypeTests.swift | 35 +----- .../ReferenceViolationTests.swift | 9 +- .../CloudKitTests/TriggerTests.swift | 100 ++++++------------ Tests/SharingGRDBTests/Internal/Schema.swift | 7 -- 6 files changed, 71 insertions(+), 160 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 7109d945..df097c7a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -389,7 +389,6 @@ } func didDelete(recordName: String, zoneID: CKRecordZone.ID?) { - print("didDelete", recordName) let zoneID = zoneID ?? Self.defaultZone.zoneID let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -891,8 +890,6 @@ failedRecordDeletes: [CKRecord.ID: CKError] = [:], syncEngine: any SyncEngineProtocol ) async { - print("!!!") - for savedRecord in savedRecords { await refreshLastKnownServerRecord(savedRecord) } @@ -1008,31 +1005,27 @@ } // TODO: handle event.failedRecordDeletes ? look at apple sample code - /* - - */ - - for (failedRecordID, error) in failedRecordDeletes { - guard - error.code == .referenceViolation - else { continue } - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - // insert into unsycned records + let enqueuedUnsyncedRecordID = await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + var enqueuedUnsyncedRecordID = false + for (failedRecordID, error) in failedRecordDeletes { + guard + error.code == .referenceViolation + else { continue } + try UnsyncedRecordID.insert(or: .ignore) { + UnsyncedRecordID(recordID: failedRecordID) + } + .execute(db) + syncEngine.state.remove(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) + enqueuedUnsyncedRecordID = true } + return enqueuedUnsyncedRecordID } -// //error.clientRecord -// switch error.code { -// case .referenceViolation: -// CKFetchRecordsOperation() -// syncEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) -// print("!!!") -// break -// default: -// continue -// } - } - // if something was added to UnsyncedRecordIDs, process it + } + ?? false + if enqueuedUnsyncedRecordID { + print("?!?!!?") + } } private func cacheShare(_ share: CKShare) async throws { diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index eafbef6b..fec7e42a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -264,31 +264,6 @@ extension BaseCloudKitTests { ] ), [8]: RecordType( - tableName: "childWithOnDeleteRestricts", - schema: """ - CREATE TABLE "childWithOnDeleteRestricts"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "parentID", - notNull: true, - type: "INTEGER" - ) - ] - ), - [9]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -313,7 +288,7 @@ extension BaseCloudKitTests { ) ] ), - [10]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( @@ -339,7 +314,7 @@ extension BaseCloudKitTests { ) ] ), - [11]: RecordType( + [10]: RecordType( tableName: "localUsers", schema: """ CREATE TABLE "localUsers" ( @@ -372,7 +347,7 @@ extension BaseCloudKitTests { ) ] ), - [12]: RecordType( + [11]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( @@ -397,7 +372,7 @@ extension BaseCloudKitTests { ) ] ), - [13]: RecordType( + [12]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( @@ -430,7 +405,7 @@ extension BaseCloudKitTests { ) ] ), - [14]: RecordType( + [13]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 8de88953..90072868 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -263,31 +263,6 @@ extension BaseCloudKitTests { ] ), [8]: RecordType( - tableName: "childWithOnDeleteRestricts", - schema: """ - CREATE TABLE "childWithOnDeleteRestricts"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "parentID", - notNull: true, - type: "INTEGER" - ) - ] - ), - [9]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -312,7 +287,7 @@ extension BaseCloudKitTests { ) ] ), - [10]: RecordType( + [9]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( @@ -338,7 +313,7 @@ extension BaseCloudKitTests { ) ] ), - [11]: RecordType( + [10]: RecordType( tableName: "localUsers", schema: """ CREATE TABLE "localUsers" ( @@ -371,7 +346,7 @@ extension BaseCloudKitTests { ) ] ), - [12]: RecordType( + [11]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( @@ -396,7 +371,7 @@ extension BaseCloudKitTests { ) ] ), - [13]: RecordType( + [12]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( @@ -429,7 +404,7 @@ extension BaseCloudKitTests { ) ] ), - [14]: RecordType( + [13]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index 7273f216..9ceaa37e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -119,6 +119,7 @@ extension BaseCloudKitTests { syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) }() } + await syncEngine.processBatch() await modifications() await syncEngine.processBatch() await syncEngine.processBatch() @@ -158,8 +159,12 @@ extension BaseCloudKitTests { try { try userDatabase.read { db in - try #expect(Reminder.count().fetchOne(db) == 0) - try #expect(RemindersList.count().fetchOne(db) == 0) + try #expect( + Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] + ) + try #expect( + RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] + ) } }() } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 8537ef01..210541c8 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -77,14 +77,6 @@ extension BaseCloudKitTests { END """, [3]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteRestricts" - AFTER DELETE ON "childWithOnDeleteRestricts" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteRestricts')); - END - """, - [4]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" AFTER DELETE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -92,7 +84,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); END """, - [5]: """ + [4]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" AFTER DELETE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -100,7 +92,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); END """, - [6]: """ + [5]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs" AFTER DELETE ON "modelAs" FOR EACH ROW BEGIN @@ -108,7 +100,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); END """, - [7]: """ + [6]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs" AFTER DELETE ON "modelBs" FOR EACH ROW BEGIN @@ -116,7 +108,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); END """, - [8]: """ + [7]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs" AFTER DELETE ON "modelCs" FOR EACH ROW BEGIN @@ -124,7 +116,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); END """, - [9]: """ + [8]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" AFTER DELETE ON "parents" FOR EACH ROW BEGIN @@ -132,7 +124,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); END """, - [10]: """ + [9]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags" AFTER DELETE ON "reminderTags" FOR EACH ROW BEGIN @@ -140,7 +132,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); END """, - [11]: """ + [10]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" AFTER DELETE ON "reminders" FOR EACH ROW BEGIN @@ -148,7 +140,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); END """, - [12]: """ + [11]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets" AFTER DELETE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -156,7 +148,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); END """, - [13]: """ + [12]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" AFTER DELETE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -164,7 +156,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); END """, - [14]: """ + [13]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" AFTER DELETE ON "remindersLists" FOR EACH ROW BEGIN @@ -172,7 +164,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); END """, - [15]: """ + [14]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags" AFTER DELETE ON "tags" FOR EACH ROW BEGIN @@ -180,18 +172,7 @@ extension BaseCloudKitTests { WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); END """, - [16]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteRestricts" - AFTER INSERT ON "childWithOnDeleteRestricts" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteRestricts', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [17]: """ + [15]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -202,7 +183,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [18]: """ + [16]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -213,7 +194,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [19]: """ + [17]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" AFTER INSERT ON "modelAs" FOR EACH ROW BEGIN @@ -224,7 +205,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [20]: """ + [18]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" AFTER INSERT ON "modelBs" FOR EACH ROW BEGIN @@ -235,7 +216,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [21]: """ + [19]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" AFTER INSERT ON "modelCs" FOR EACH ROW BEGIN @@ -246,7 +227,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [22]: """ + [20]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN @@ -257,7 +238,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [23]: """ + [21]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN @@ -268,7 +249,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [24]: """ + [22]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN @@ -279,7 +260,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [25]: """ + [23]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" AFTER INSERT ON "remindersListAssets" FOR EACH ROW BEGIN @@ -290,7 +271,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [26]: """ + [24]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -301,7 +282,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [27]: """ + [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -312,7 +293,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [28]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" AFTER INSERT ON "tags" FOR EACH ROW BEGIN @@ -323,18 +304,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [29]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteRestricts" - AFTER UPDATE ON "childWithOnDeleteRestricts" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteRestricts', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [30]: """ + [27]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -345,7 +315,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [31]: """ + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -356,7 +326,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [32]: """ + [29]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" AFTER UPDATE ON "modelAs" FOR EACH ROW BEGIN @@ -367,7 +337,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [33]: """ + [30]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" AFTER UPDATE ON "modelBs" FOR EACH ROW BEGIN @@ -378,7 +348,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [34]: """ + [31]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" AFTER UPDATE ON "modelCs" FOR EACH ROW BEGIN @@ -389,7 +359,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [35]: """ + [32]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -400,7 +370,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [36]: """ + [33]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -411,7 +381,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [37]: """ + [34]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -422,7 +392,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [38]: """ + [35]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -433,7 +403,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [39]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -444,7 +414,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [40]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -455,7 +425,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [41]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 7ddac099..49d3e0ab 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -147,13 +147,6 @@ func database(containerIdentifier: String) throws -> DatabasePool { ) STRICT """) .execute(db) - try #sql(""" - CREATE TABLE "childWithOnDeleteRestricts"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE RESTRICT ON UPDATE RESTRICT - ) STRICT - """) - .execute(db) try #sql(""" CREATE TABLE "childWithOnDeleteSetNulls"( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, From 1e827807249fbc198ebd7cacce00a60abac83c83 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 17:03:52 -0700 Subject: [PATCH 401/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../ReferenceViolationTests.swift | 92 ++++++++++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index df097c7a..8599300c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1024,7 +1024,7 @@ } ?? false if enqueuedUnsyncedRecordID { - print("?!?!!?") + await handleFetchedRecordZoneChanges(syncEngine: syncEngine) } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index 9ceaa37e..9a373108 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -94,10 +94,6 @@ extension BaseCloudKitTests { $0.date.now.addTimeInterval(1) } operation: { try userDatabase.userWrite { db in - try Reminder.insert { - Reminder(id: 2, title: "Take walk", remindersListID: 1) - } - .execute(db) try RemindersList.find(1).delete().execute(db) } } @@ -169,6 +165,94 @@ extension BaseCloudKitTests { }() } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test( + """ + * The local client deletes a list. + * The remote client adds reminder to that list. + * Remote syncs to local client before local sends batch. + """ + ) func foo() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + await syncEngine.processBatch() + + try withDependencies { + $0.date.now.addTimeInterval(1) + } operation: { + try userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + } + let modifications = withDependencies { + $0.date.now.addTimeInterval(2) + } operation: { + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + return { + syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + }() + } + await modifications() + await syncEngine.processBatch() + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try { + try userDatabase.read { db in + try #expect( + Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] + ) + try #expect( + RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] + ) + } + }() + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test( """ From e004f327e5f363fb6e0952f09f4fcc10fa6bd209 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 17:07:10 -0700 Subject: [PATCH 402/581] wip --- .../CloudKitTests/ReferenceViolationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index 9a373108..ab6ce235 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -172,7 +172,7 @@ extension BaseCloudKitTests { * The remote client adds reminder to that list. * Remote syncs to local client before local sends batch. """ - ) func foo() async throws { + ) func deleteList_RemoteAddsReminderToList_Variation() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") From 6bef2712f956effc4a5cdbbf5c69781bcabe784d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 17:11:11 -0700 Subject: [PATCH 403/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 8599300c..03b372fd 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -948,10 +948,9 @@ .delete() .execute(db) case .restrict: - reportIssue( + preconditionFailure( "'RESTRICT' foreign key actions not supported for parent relationships." ) - break case .setDefault: guard let recordType = try RecordType.find(table.tableName).fetchOne(db), @@ -979,7 +978,7 @@ ) .execute(db) case .noAction: - reportIssue( + preconditionFailure( "'NO ACTION' foreign key actions not supported for parent relationships." ) } From 96dae93ceb58e052ddb930f929bbc518187950a1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 17:16:21 -0700 Subject: [PATCH 404/581] simplify --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 03b372fd..f2d6cd4b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -329,8 +329,8 @@ package func tearDownSyncEngine() async throws { let syncEngines = syncEngines.withValue(\.self) - async let privateCancellation: Void? = syncEngines.private?.cancelOperations() - async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() + await syncEngines.private?.cancelOperations() + await syncEngines.shared?.cancelOperations() try await userDatabase.write { db in for table in self.tables { From 838690d2ebd09cf864fa47dc4c0cdecdb84efbf1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 17:16:37 -0700 Subject: [PATCH 405/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index f2d6cd4b..043dba9e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -329,8 +329,9 @@ package func tearDownSyncEngine() async throws { let syncEngines = syncEngines.withValue(\.self) - await syncEngines.private?.cancelOperations() - await syncEngines.shared?.cancelOperations() + async let privateCancellation: Void? = syncEngines.private?.cancelOperations() + async let sharedCancellation: Void? = syncEngines.shared?.cancelOperations() + _ = await (privateCancellation, sharedCancellation) try await userDatabase.write { db in for table in self.tables { @@ -351,7 +352,6 @@ try StateSerialization.delete().execute(db) try UnsyncedRecordID.delete().execute(db) } - _ = await (privateCancellation, sharedCancellation) } func deleteLocalData() async throws { From b729883f757b9c8afea06f21bb64ccb92c38793d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 17:30:08 -0700 Subject: [PATCH 406/581] Delete records in groups. --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 18 ++++++++---- .../FetchRecordZoneChangesTests.swift | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 043dba9e..e671a52a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -856,16 +856,22 @@ } // TODO: Group by recordType and delete in batches - for (recordID, recordType) in deletions { - guard let recordPrimaryKey = recordID.recordPrimaryKey - else { continue } + let recordIDsByRecordType = Dictionary(grouping: deletions, by: \.recordType) + .mapValues { $0.map(\.recordID) } + if recordIDsByRecordType.count > 0 { + print("!!!") + } + for (recordType, recordIDs) in recordIDsByRecordType { + let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) if let table = tablesByName[recordType] { func open(_: T.Type) { withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in try T .where { - SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") + $0.primaryKey.in( + recordPrimaryKeys.map { SQLQueryExpression("\(bind: $0)") } + ) } .delete() .execute(db) @@ -875,7 +881,9 @@ open(table) } else if recordType == CKRecord.SystemType.share { withErrorReporting { - try deleteShare(recordID: recordID) + for recordID in recordIDs { + try deleteShare(recordID: recordID) + } } } else { // NB: Deleting a record from a table we do not currently recognize. diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index ebc9312a..51916899 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -419,5 +419,34 @@ extension BaseCloudKitTests { } }() } + + @Test func deleteMultipleRecords() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 3, title: "Get milk", remindersListID: 1) + Reminder(id: 4, title: "Call accountant", remindersListID: 2) + } + } + await syncEngine.processBatch() + + await syncEngine.modifyRecords( + scope: .private, + deleting: [ + RemindersList.recordID(for: 1), + RemindersList.recordID(for: 2), + Reminder.recordID(for: 3), + Reminder.recordID(for: 4), + ] + ) + + try { + try userDatabase.read { db in + try #expect(Reminder.all.fetchCount(db) == 0) + try #expect(RemindersList.all.fetchCount(db) == 0) + } + }() + } } } From f846e5f5e959c867bbd9be1da3ba4d5e9277b506 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 17:30:53 -0700 Subject: [PATCH 407/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index e671a52a..1a87f907 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -858,9 +858,6 @@ // TODO: Group by recordType and delete in batches let recordIDsByRecordType = Dictionary(grouping: deletions, by: \.recordType) .mapValues { $0.map(\.recordID) } - if recordIDsByRecordType.count > 0 { - print("!!!") - } for (recordType, recordIDs) in recordIDsByRecordType { let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) if let table = tablesByName[recordType] { From 0116805e5bc978e2e0ebc0b109934cda3307db95 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 17:31:38 -0700 Subject: [PATCH 408/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 1a87f907..17eb5516 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -855,7 +855,6 @@ } } - // TODO: Group by recordType and delete in batches let recordIDsByRecordType = Dictionary(grouping: deletions, by: \.recordType) .mapValues { $0.map(\.recordID) } for (recordType, recordIDs) in recordIDsByRecordType { From 986a984f7c8f973bf0857da914fceaa2da766b10 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 17:31:55 -0700 Subject: [PATCH 409/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 17eb5516..9742e176 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1006,7 +1006,6 @@ continue } } - // TODO: handle event.failedRecordDeletes ? look at apple sample code let enqueuedUnsyncedRecordID = await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.write { db in From 7d2c5c71c1f5aa5f0e5e783c5b9870e54db5abf3 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 16 Jul 2025 18:25:38 -0700 Subject: [PATCH 410/581] some doc updates --- .../Documentation.docc/Articles/CloudKit.md | 108 ++++++------------ 1 file changed, 35 insertions(+), 73 deletions(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index a4d1271c..ea6659cd 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -125,10 +125,10 @@ versions of your app. There are a number of principles to keep in mind while des your schema to make sure every device can synchronize changes to every other device, no matter the version. -#### UUID Primary keys +#### Primary keys -> Important: Primary keys must be UUIDs with a default, and further, we recommend specifying a -> "NOT NULL" constraint with a "ON CONFLICT REPLACE" action. +> Important: Primary keys should be globally unique identifiers, such as UUID. We further recommend +> specifying a "NOT NULL" constraint with a "ON CONFLICT REPLACE" action. Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a primary key by using an "autoincrement" integer. This makes it so that newly inserted rows get @@ -136,8 +136,8 @@ a unique ID by simply adding 1 to the largest ID in the table. However, that doe with distributed schemas. That would make it possible for two devices to create a record with `id: 1`, and when those records synchronize there would be an irreconcilable conflict. -For this reason, primary keys in SQLite tables should be globally unique, and so SharingGRDB -requires that they be UUIDs. We recommend storing UUIDs in SQLite as a "TEXT" column, adding a +For this reason, primary keys in SQLite tables should be globally unique, such as a UUID. The +easiest way to do this is to store your table's ID in a "TEXT" column, adding a default with a freshly generated UUID, and further adding a "ON CONFLICT REPLACE" constraint: ```sql @@ -162,6 +162,19 @@ try database.write { db in } ``` +If you would like to use a unique identifier other than the `UUID` provided by Foundation, you can +conform your identifier type to ``IdentifierStringConvertible``. We still recommend using +`NOT NULL ON CONFLICT REPLACE` on your column, as well as a default, but the default will need +to be provided outside of SQLite. You can do this by registering a function in SQLite and calling +out to it for the default value of your column: + +```sql +CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (customUUIDv7()), + … +) +``` + #### Primary keys on every table > Important: Each synchronized table must have a single, non-compound primary key to aid in @@ -180,7 +193,7 @@ CREATE TABLE "reminderTags" ( ) ``` -Note that the `id` column may never be used in your application code, but it is necessary to +Note that the `id` column might not be needed for your application's logic, but it is necessary to facilitate synchronizing to CloudKit. +All BLOB columns in a table are automatically turned into `CKAsset`s and synchronized to CloudKit. +This process is completely seamless and you do not have to take any explicit steps to support +assets. + +However, general database design guidelines still apply. In particular, it is not recommended to +store large binary blobs in a table that is queried often. If done naively you may accidentally +large amounts of data into memory when querying your table, and further large binary blobs can +slow down SQLite's ability to efficiently access the rows in your tables. + +It is recommended to hold binary blobs in a separate, but related, table. For example, if you are +building a reminders app that has lists, and you allow your users to assign an image to a list. +One way to model this is a table for the reminders list data, without the image, and then another +table for the image data associated with a reminders list. Further, the primary key of the cover +image table can be the foreign key pointing to the associated reminders list: + +```swift +@Table +struct RemindersList: Identifiable { + let id: UUID + var title = "" +} + +@Table +struct RemindersListCoverImage { + @Column(primaryKey: true) + let remindersListID: RemindersList.ID + var image: Data +} +/* +CREATE TABLE "remindersListCoverImages" ( + "remindersListID" TEXT PRIMARY KEY NOT NULL REFERENCES "remindersLists"("id"), + "image" BLOB NOT NULL +) +*/ +``` + +This allows you to efficiently query `RemindersList` while still allowing you to load the image +data for a list when you need it. ## Accessing CloudKit metadata @@ -311,15 +361,16 @@ let ckRecord = try await container.privateCloudDatabase ``` > Important: In the above snippet we are explicitly using `privateCloudDatabase`, but that is -> only appropriate for unshared records. If your record is shared, which can be determined from -> [SyncMetadata.share](), then you must use `sharedCloudDatabase` to -> fetch the newest record. +> only appropriate if the user is the owner of the record. If the user is only a participant in +> a shared record, which can be determined from [SyncMetadata.share](), +> then you must use `sharedCloudDatabase` to fetch the newest record. You are free to invoke any CloudKit functions you want with the `CKRecord` retreived from ``SyncMetadata``. Any changes made directly with CloudKit will be automatically synced to your SQLite database by the ``SyncEngine``. -It is also possible to fetch the `CKShare` associated with a record if it has been shared: +It is also possible to fetch the `CKShare` associated with a record if it has been shared, which +will give you access to the most current list of paricipants and permissions for the shared record: ```swift let metadata = try database.read { db in @@ -340,9 +391,10 @@ let ckRecord = try await container.sharedCloudDatabase appropriate to use when fetching the details of a `CKShare` as they are always stored in the shared database. - - -It is possible to + ## How SharingGRDB handles distributed schema scenarios @@ -372,7 +424,15 @@ It is possible to ### Updating triggers to be compatible with synchronization - +If you have triggers installed on your tables, then you may want to customize their definitions +to behave differently depending on whether a write is happening to your database from your own +code or from the sync engine. For example, if you have a trigger that refreshes an `updatedAt` +timestamp on a row when it is edited, it would not be appropriate to do that when the sync engine +updates a row from data received from CloudKit. + +To prevent this you can use the ``SyncEngine/isUpdatingRecord()`` SQL expression. It represents +a custom database function that is installed in your database connection, and it will return true +if the write to your database originates from the sync engine. You can use it in a trigger like so: ```swift #sql(""" @@ -385,8 +445,12 @@ It is possible to """) ``` +Or if you are using the trigger building tools from [StructuredQueries] you can use it like so: + +[StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries + ```swift -createTemporaryTrigger( +Model.createTemporaryTrigger( "…", after: .insert { new in … diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md index bcd4daa6..4b90e9c1 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md @@ -15,7 +15,15 @@ Info.plist with a value of `true`. This is subtly documented in [Apple's documen [Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic - +- [Creating CKShare records](#Creating-CKShare-records) +- [Accepting shared records](#Accepting-shared-records) +- [Diving deeper into sharing](#Diving-deeper-into-sharing) + - [Sharing root records](#Sharing-root-records) + - [Sharing foreign key relationships](#Sharing-foreign-key-relationships) + - [One-to-many relationships](#One-to-many-relationships) + - [Many-to-many relationships](#Many-to-many-relationships) + - [One-to-"at most one" relationships](#One-to-at-most-one-relationships) +- [Controlling what data is shared](#Controlling-what-data-is-shared) ## Creating CKShare records @@ -150,7 +158,7 @@ you can share root records, like reminders lists. If you do invoke ``SyncEngine/CantShareRecordWithParent`` error will be thrown. > Note: A reminder can still be shared as an association to a shared reminders list, as discussed -> [in the next section](). However, a single +> [in the next section](). However, a single > reminder cannot be shared on its own. For a more complex example, consider the following diagrammatic schema for a reminders app: @@ -308,35 +316,34 @@ excels at. ##### One-to-"at most one" relationships - - - +Here the `CoverImage` table has a foreign key pointing to the root table `RemindersList`, but since +it is also the primary key of the table it enforces that at most one cover image belongs to a list. + ## Controlling what data is shared It is possible to specify that certain associations that are shareable not be shared. For example, @@ -356,7 +363,9 @@ Sharing this record will mean also sharing the position of the list. That means reorders their local lists, even ones that are private to them, it will reorder the lists for everyone shared. This is probably not what you want. -So, private and non-shareable information about this record can be stored in a separate table: +So, private and non-shareable information about this record can be stored in a separate table, +and we can use the trick mentioned in +by making the foreign key of the table also be the table's primary key: ```swift @Table @@ -366,9 +375,9 @@ struct RemindersList: Identifiable { } @Table struct RemindersListPrivate: Identifiable { - let id: UUID + @Column(primaryKey: true) + let remindersListID: RemindersList.ID var position = 0 - var remindersList: RemindersList.ID } ``` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png b/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png index 466bd1f4826c66dc994987912a45752a5d97f4bf..7807c6024ca9ab0c74fa8cda5ebacb3e00ac4a40 100644 GIT binary patch literal 52677 zcmeEuby!qg7cYz;C7}pNmx9vL9U`DK(p}OsbSNDXii9Ga2HiDuiHNjF&oG2^gVHs3 zkMH+>-+SZ#d!OfiJkKzPbIv|{@3q(Zt>0RE9bRgxD-jdY5Mp6r5kG$P@Cg>y6XPm5Lc-U>XT)}FI=1p9FpN6%lLip5;8N1~1y+W=hq)gbb9PEsc ztL99}?p9VTSX`Z3vwa)1SXyv>`gca?O?0_}%H6j_Sh7}3gJiw9CuEEeEEkGLHn>=1 zx$z>|hD^FY-J`soewT}KN)%NIH%=6l59cLC-2URm9C!`OFmou`20LdM@44Q4CZhH4 zmNo5Fmn-&fY9xJnNxTI0Druhesrs5wh~iPOYzlo zralTo#Hg09@iAxJ9g1Ed-gA2pXd(I~g3M0&*9Vpe=qrJ)4rSUDnVKgi?JB_rq(6<` zNZt+nw)_d|JH6X1!f#_TNQQpROS>;uh&Fta{!MGm8ZpH(6ED4N@Gdj{+I@0E+@v5ppt5cX?A%mZiD9VQA@uOg6#^P`9aJz}8c6yUZyJIu^DSv;6 z*i8)Ge4(jZR?!W)%t7-}mT26bzfH0cwV1#XVSW7-BuuAr;pv;(_szN%e1C)ni;fvg ztEW*3JdMMP`t{&lGxZmOU!RZXE5mVJ-HP`2ZaB!PhW2VSb3`&ye*A?ui^qaRg#)3~ zY)`|^3!`J&UWKiP`xIqawBzx|<34P+jG|_EBRR<$GVx3kCou96&LZmqtEw|tSI>i635JX8L8xNfXf#lX^?n<&SJD} zib@Y?-X^VEj>mMT_c_v3FVUt>f3yjst@9i!A-mD@U(MP$pwyIW_Caa_s)=tn4%0!|)1X zM%c@MX!|k)k5)9FE zB|OLH3GJH`ufL+NA@YpmwB2bJTaN6;1NmD~(pI4cEQe7ys$2Ku$C-4$>3t%ZildWLXQql*Uljg@wI+A@aOUH!qS*Bu#R3X(2Ahv`f^=%K z`8kHG*J|)hL(612hiN=T5K&nVct1=G^X!Wu;;>bTt*!D>u6+GkZy=cT%EpQxDy{cQ z*FwnP$~YwZw{8j1M%T>z!;Fw>SG@XA`MrlG!9(pU3w?VW&6F-*hUWD5@SZVVrpmJ3 zy0J-MhQnZagE^WnLGwWY(`qucy1#~+f`Q)x$ueG&Vz^hNDU%a?>N z>OWbnWL`e}!9JDX|7iHObYZa01B5I>Oq(~B>9T)0drBNMxeo@QDeq|&5Cq;aU$rFo|W@>L6~2yzM|oxF!K z90!Gm1bIbV1a_+VDx+#roU~l_qVIIQg{8t8W`1QX(Q-N!KP}GHQ_~wNIVgEPt}uQu z?gO(I4ipxEIzr2)ZosDC4e)d*FY>*&v-gzun0NA~mYpxX<#tcBYPj$cup znm%))H^oOKBwe41D~eJ|2#dAea=9<C^-1*$^NWiNS8D85j7x{o zdtdVtJ8Nd|5NP)Zu`6DY;PokolwUCmdM5E~#dqgdpzyHo2m7CgFXR0C{g0)M0~-Ae zTi04=&%94kPxmey&lyhB&xkI3j%Ai6-Q7i-#CRbYkf+#@S7uyW>VMa>U#V=5Z~xIA z%e0mAC8sFoti!g$u;ZX3fZ3VZ)O$G1;%4S4N|VhM0`u$hd-J;U zq^z4PPp@;ov43i?i1^l|m8A8yAkUs`Ali&JfcLAotz!SMffZC0>b035x>DcZw(^sD zZ{(*>SDQNiBZE{|_F*=yH^5pdjHY&e8K#;PZFN^ zd^*%1No#g?_enE0dW?v9^DI1$Fc5 z80r8>nMKQ7!*_E7p3UngQR7irwx>_adi;}?5^9Bu*^#_WEKU0T6#6gCmtQPHd!8rI zW;O}c{n>V0iJyp zav_f*38S}>kluQ?xmgrbETz4orC-OJ z9bUj?0?;v6=J60DRiwgnRgyJ(d<(7)tUpAXD$DOR6&8!-HvE1x`36q8lhmn1AGZw^ zf|W|=`9YW5*7xpo#G5Qhi0P|7T5D(Z=-As+Gp0^u9-#TAd^T_aE zA3V!&X_@ny|E95-oyOB>4u$$S$(&ashax4WC1`k`n>e;^K5xkNuv*#U4AfUQC2l@l z5Spotp7>biGxMm~ZNnBZP-gtuc*uDL6?mdD&&#;Y)vQu)?5pwHtSg}NOsEZU*;v=t zvN0Lfj<-d+z&P{#_Ot!-%JaSM?K>Lw8XQ?^lBIsjze;M^>sNlcBm`{)@&sJIuUqsf zofT=Vc<#1Jik3l~u^o7|y=$R6_Bi3Z(3e2FUlfoY)_vp z9%;z*pFcV^IO#dm?pxU%^1=IsclDD$bn+wI@`Z3qjUS1|a={6wk|S6Nq|uknIvZ#IlZKt9(V1Q@Su3?AF6sbO(|V|*+e zY#J-);v2rNAC*A4J_ zmIL|w?kl!ASN=YRq=9Q#a@z8bAA@ggYj+zPXAgT9PrmLl5bt0SxIQxUz`~-ugZW@T zesX&kJb&Cl$H3D-O;z051;%6f)Wyn%=NZfu^BgS6XX4-xX5(qe^b7`d_7Hz2#q#G4 zad3<|&C9~{=N3;VDHa1YO(uC4cN->Q9zGsE7HL8zCMHSur?%ox9xDE;IrvM8#op7? zRh*aC$H#}qM}Wu0-H!LZn3xzZA3rZYKR398+vB;jr{yzlXAjoD9`g5d9@=KWj#lnK==-)qojnn3t!+&~m_W0Mdzyx_QS9tI9@bUip*`TQ;=B&7; z!!sMG;X?-)kQwMhT3A?E^3VPM+m-+H_>Yzb|7j^I!uQXX|G4tMw$$~oahG?2fi69z z|Fc~GYW&ZO|7s}7i<$X9B=MJ+|C|MKmL`SjO^;yD(UEY{!@3^l|C6;d9e)Fm1-r_wL zk|BT6l|}({!*BDnvup{0?BBlk1$?BA z0+#pskAeoJeBQJ@>AH*06pRJ=&o5kZgBKQhe17V$|9KW)6g!mK90!L?77P3DUpeg% zoN9;&R=(1I&VeI*TYtQN-i8$dAtLK1oW{yv_;*uG1E!Qc`hPqPoFe3s<>Pd{;_CZ% z4}YE;Z1L6S&%wWc-L1gtC}}5dU%UQ4wGXA9{hvCJ{qF$(lFxr7_+K6T+fx2(B>#WF zOz*?F;_}ui?=3`P&zO5Ez<1di**FHN62GJEVcLqT04eVV0~p0x&A1Wr03QJK`$4hm zvP>@lrr(AWdOg4bfDiDr_hGPw9vkt14NI_WF1V)rauS2oOaQ5|#Pc8Kkb!d#3Ov+t z$R2&b>fm}=`XG}`_7C8M@L&IS%~qwXrU>RU9AM*qV8RB$e^=d(M_vw(V~Zid$G64C zbs>{wk^_&x5xS}?$E1!OYM9YTM~jW~7PtZx_YF-E9I{;qjsR~5C*%$f=yUJt@-NWj zNw5Xw<4!`*X*}puE=Eq5EJ_=k%jfA<#0t3${LR~G@lj%IoMxrtE zNgT4Mn@lMRKPn*DI5%#CPBg7GDR6#*b6tgfl|TSJ7y;Cgc*$d7^UCu5zR}F0`4gPF z;bOr8GWez~!5$V+7#xx3aU0KGh#HTp=8n6y&i(*3?cJLau+ z#yv*D0UKBal%PqhzOpl6t%b?oC0HA~_ zCT{|OX9Hk+ONw1F#~YxVE|G1kE@h#1HzLdLBWxO8ZLU*OUPss35^yrEs*hG!=WZVIkmox~zHC_|MFg<#Fmh?`ST+%T#m%N#9&l;vulC8Cc9?BJC)JA1{|pz? z8QxLh<2+?sgpl1O0(~0_3Q)ZartaX9O5tvpeg!{YcS4#6T{yJ$pL`8GxuiYY7?SzX z$`Q@mQbI3&>R|jlGv-GU6*7`N;;4GXh2dvWi@#&N?UA2dD#YkLVaKNV^MiM$$iU1T zyvhS=R#^2#6S_%`c(z4sjWV6TAek0!?9#&KJ;}{GzWXS*lD4Bn5b>s1HfIz}Id|r> z2_pmz9OI9$sn)QassryS_TxaV<25`#pLMCjeJ`rXICKA-T;@R&7uubD+xXH{3$(<^ ze%3!k)6Y(n#-GlzLUSWxhYe0eUV~F|Z0_yyEIrt2Nruxse3@qdGh1 z`fpp0`2<)~#^zMM@Zd9PZUBE{#J=(Z|E6NFMUq6yiQdU&!j!j49X+&TdI$A^V%&bw zH|5;djLIz70=^rONFQR6#Pg6(q?roeaCTCg3z%x8SMquwN9 zAGW}F@ioc7JABULcp$qCZ9Cg~6yf0hMj_eYhn{oIn{+Iz$~(y{!EL}mM7W3**I8^GpDLQ>+)15Rw=35&MRb2 zb;^Y;$ei=9IGrsVnO`U!H6H)yOO0@g=xcV7_K_D)HnOxKTECbuKSV{kAd_8MExag= zS%j=D$VI4Mu_+ZgYVj^ol}lO6_+P#+jZZtMa_&{EHq#SHxj089AC4LGBBslwD=BD8 z(rJtuE4k~|)5MDJqa$6y=0oN;P7hWZ4%ebbU^8{*IDzxl4iR5m?An}V{14?E?KiT^ ze6*z0r)<|vQhHrs@MpH}(Ar0V2m(;dVWF@hwoIun|BguF0v%=9; zHF`uh$ouwP9&<~buSOmqjO(gU>MblATmgukTnWXLZOe_oAqgIw>De8!^s}z+dF}cG zq^DFDd?PULV*kLd&@UoZL#_Do(Yim;54h9xhnBUOPc|E+S#bZS8)K?6tDOqSZIQ(+ zi#KJ~iwr?KbzLFd2mK;u;k(w~13a zs-$NoGGA@0jjv;~3mMtuM3k+xgZ3B^fUkcw#*HpEE~j#C=x1m&0c(d<$&hEmV((Fys|DD#ELlh?^ae4h$@k*C`(-hzrU-TtIi`Gr}VW zWe}qy?~3hbGkvdBETh*F;8UZ_>{BWWCSBoZWpQZ~@=DKRpxEt|nm8Tc zY%aocL62T;Y)-;Ii*r9)2ua_16YZymA7?0{&fQPhe^Zfx-v2|(-rQsXDs-tBHr;=y zj=EC+z>y`{wP9^;;7P|Otj@kKEVeE#EAs}9P+O)i`eq}A{mxoTUBEHaxBTtwV4Eq|k2(`&C$hKHq^7eq>zS8H=@p4;#|wEt!b z|L{^`eCmDa27%wvuwUrBFT%Tt^=vA(`;gSTY3JP)Xh+;^)BB{>6)t@~B5&0eEdz%d z(+!>P#)Hy}jqzs(wX4V``g{Fq0 zijT~wp!x5koLrjHx!W8&b%y5i3%{SITu81ITU;0w&h*4~qLYPj_~`o2BM5ufD~+wD zmbO~h8C)kOwnHm<;!yz)B-&E_u#VCOyxpqZ91Rw_c~w1_?^P!|ScXnDjciw^7S3?j zR!4N4-XO6hd^J{@e3`R5-dTrSDP;as)btAB`cW9~M7w@M4e3=WR5vXKGrSkXl#=Ma z;SR%p7Yn?&C*m!q98*4E|4Mu+D2;;=k?dUCuT5QoH&u^pPkzkz;*}1=OVO7ph{1!r z+`xijdr=gFof&MS?(|4Z$4L5Y@8#ic@W)Wg7lhiTE&)9ka}%fi7I`@%^RMurg%leO zwQH?HQe;M61j+qoBq%#?<0H!L?iTn+%R{B<1KVd*N0&i`%>IqSiOOMy9FK~UU67qm zI_EDNCLSNvO1Un--)@|idxBo~@4O}wor5mvdE&fT@9@1iKfci^o7tg--HLjvjX^qi)vFH%rZ1pratsC2G4m@ zrm=m)uiw##m;QB+4CKjCghkSJ2RjI$@_-0aw;q&|eQN?oQYj9PX5RLdF0#pf7wF%Y zm#T)=bjuY+UR+9KY#*GtR7Gmb-wGIxm&MJ5Ds=8Li0^!}7?abNq1o4b5tho9wJLsi z!u0NhKG1wOI>ug?ceP>ULJc-pCi=zoRM&3GL&Wr__ehH-yX2mA_}X|edB0JX;AEQD z_Ez!r<|2JNz`(+W{D^N^)9yTfw0lz7r(T;{XU5>~95h@CpWnv2njI!m*zTneO%eJ& zcHSb-ugzJ!pzvNvK>_=(9H%_JdWO=GfB@?zK~mpeCgoF6ku|-E{T4~o@N;k9form; zuWj3G;sh=UTLCb<>Y{NKuRAHZb+#moH-e1ydk79M z^S2L_kLCK0vfUniSNZtB2)5<QTdxwH<~9}quYooka{oK2$PIo2Sxs2G-Unekl}PWK$N?D%L-5oR?=aqwzAC!-!elT>D(raDHxbC%PoY($I_Q}u4|X4g%-cEl^NxVYTXRj zgKf+8TzV`!>G1Vk6HkZlq@@)F?e(L=h5#ka3rI?{8BS&(4Pw}rj6gajw2-I~%hPR% z6z>)Z(|CNNJnYQh!P^ilZQGtgJ{uY=JX=V@Pl}vj?f=Nfk{Ey<2b7i?BV5|fH}lGls&5lY(~0}buSyql8kfCG!}&?tR$x?~MqM%1 z(>X1Da?RGfRcORn%>OKh$L(shaoqG&>$3|(pEBFX40~?vqxi!9G~YPX?9r@W6$!u| z{qns$XH$5P0&cZy$~U;VO`osoyC}q(Un9pKr;bX1Afrh561aFP(0JR}iA}T2s z*^yr&sH3Qw8EqK358sHG;}hO3D)^|!BuUef0yhux0UswaZ{_p#yBt6hwS=e=6>^q= zH{Q2^yEw8$0cPACa(UP%Ibr>4fr_zVh^F<~YK%snl!mgD!uoweDj7qiXNIGG#1}{? zdfVBn@~VKE;D@P8ozESrLsBCFMy>m4!t5|aVB2(?B5pO_(E3|K37_ME(3x@zxmXbu z?zSh0FYNOc%h|d~);@L0TQXZe60{RCRPosu_s&XM*T&)K+@oBz2Ty1_R9DKKTWUx< zR0+|Pp;K*?%^>ZvNC?tFNa4|IARlWbUw_&1?)~$6@p;qY^H#Qw zenu1WOi3@Vp!AC`Z9`lmjk?0qpQW%66NT_lL$8h8EdpD!tsVVP!$!Ppkoidka(fCV zs(rw;1CFLUROiUg7yvnM5viEXJ-Rb~{yiadXkrUFxg2)+>U=yX9J89H2wF6}GxapsRNgD}$!tmPh@UPvj&2#ll`V@0F4DKF;-|AbfG$UgYz zbtj1pKXt@}^fQhFrUe~C_$AHwR;<1@DW_RSK-plMGIvVAQ3rB6i^qj5YxN5+5oZ1R zU#bGEv2bMTMMwCQ{yOu=5y(_+u_aeuBg^nf?^LhNjBEQIz@x)gTw-R#NMfr}t}`3! zw<@5&;(kHZQDS18wV{|(4;V|=nAs?02n`60IY@0k2kgN)VTg)^D=S_;h(46@CtTjw zO`{BLA6zP&qhqC1Ndsaze0h?yxRVUQu+~zoCbnDD5uLfD8n}qWaU+@EKTss2+XKnOBM$&{iCMi6<) zjqP%{v=NGO#_#Q4oT*-3_wuw-*Y(^0=*j4nMx-ep4=ht!hchLY!8hOno3_zn{KG`o zIPl(K3%R%%qd#^ZvZQuEcS7p&8FVYmDqKcs$EGys5}X?Si!EC`XGpVrzio@H?exI* zpI2YRRiQK(8K$f2EVhr}lTZ2{N)yVSy?er(lK8~Qog4pM8HN%elQ`v>)c%S5rou@N_IV?{XuEBekM6f)WC}_@n4Mwq$u{SY z#3)ZRVUSGVrDcVQOJi)h!@&gF&wCI!Yq(`B>AY()XUbTOnRqQ*F*T~NaNz7PI)^Py zxb4zcO>HRX%=XX;KKtDdhN4Ga-B39p?kIU@oa`i@;|Y{FcO=P#03!C6I}6PauZi<( zO;ys3liv_1b7vHq>SCojdUQ6tQP<~h3QJhdo6DIYFju}0Rvi;?H_1>Gg)%4vb%>c& z;|d%sONxK0YdL5$_SifbD$*%ma?Uq#HujX{fg#s%s=FgHtpr?}SYT)_}M+w@LmneM6m1{CO_U5)9?T%L$(1= z;J~U-shdhFLHQ_7f#N{ao`npxu%OI=MHh(VES$-XnN~W8l6uoP3q4#Q=iG-lSdkrU z+b@4rLohfdAw*0*%hb~fJ2?PmLQ+mLW8Q|oDi=3U77&5Zi_9P^`Ry_qCIaspzx?Rc zwu7>$y3w{f|2wW~Vux`-x@^K-26^;$FEH^Qj%lsZIz!QDg;=6GD`REEfH%Q-vo6|oj84qqanq{l@gnsj>ER!u@$p{mp$AtBw7(! zFwjPtPr|5yecV$#Op` z;U zYLrD)AHf`tGb5JP7QYlPHr%zEUCO*V2XDIKH@WN z)H>j1P~apc?Zx;Y_={Q~!K9>05-$4|sP$uK`t=Ab2nPRtjxJ3C;>rsewcZvZfe=tJ zrQB%_DPVx$e*yW#=$ND{tiiGjpwaU$i<1%efL)_QB6K^zi(nTsmTi90KgGXH-rB{|L_;h0jvdS$3|g6 zh`VuJ3&Gbh2J{HV?=72y*+cG{avr6@#`y&r z^|jqodWaPg4gi$9nOO<+ZA5nbdq}1fhJ+uYd}havq3jv1q4a^r^FlM(^w;m=hLB1h zx06U8Ta2LdE>Abg9nGI)ihDuZgR!@R;>KoD>Hm~u{b&376T!4M5+>}q`km$?1#`Gn zR9vQvxTD30L&)OVP46>#C)wo5M-P|AwLD?l{uG+6KJ`CEV#B}>*wc=KdIAWpxc8?0 zX>^coA4@dXPvMq}Lu9Gj!dG2X%of?*=ONnaVqJM^>3XFbCeX^R&Bl$gYRsQbGQx_O zQmz6MF!_e-#^6bzIG_j1fYW`q&Wa+V(gY3ZDRG}|ld*v*l8`F0V(I@30ATXos9_ zHw_KSpwBr>FW#!~I2_IRm6ulaa_AzmV*UuFh+USiIE%^{_@)qCHq{i1kBTw;1N^DQ z_Ms5srXMYW#I(Zwwf8p2?mC48UYt&KlFFFexpE-6(b3OYTJt0kZpVr#SPLlv9ID8< zD+%o2$cE%+`Szlazkc=cHW`hU8c!_urzTV@o_0*VmygUqR@?}2r~0m%50jQskCMI?ZcwXZK0j(LLX+?4C|jjG<{jb8pv9n7!wI9*wE@)6@o?sVz-{QU@!F$sqIvEZNk%(%8A7 zE7NYTi^@j^BJ{d9dA>6exjOt2?CcaVzwYB{Dxv-St%c7)&|j7*KR8P*@BQago2}Xj zc+*a+gu(goLR?eu;a6IhdK<9cRtu{cR&;)nWx+c0r||IKHh>jUkNY*2-f63 zX|itWgIasrgvjE9|OTx#Iz zS_oWAp}N=9wF9?9KL21rGw38AXypNJo_eMuSV+f;1xjP+e8derBL124L!`@gP&JM7 zrb?YNYIF5toG>a`@vlxAK_}~aP@t9f37UWPa+XD^lKU4xoAm=m?H{xcpHTIir^^i9Y|G$N0iG zexH!DL^IQLiV_>A3V+&jL@kdw27R#;R9zBdw*Nzgw?U>#@^`<0^Tz>UWdHtj2fgToDa2&Lq;Q*vR6tP;TL^o${|_l9#L#M!zDs>v zr$7UxRwaMAzdGC*EBZ4kW$>#dsO}8;34&<~pEE5#oL7pn0(GWdW4g}Y-gBVp-K>wO zJaGjGK}k_r@U{%CkmI{TDm9xhV&RG`DLCJ{UHSd0mR|11Qk>A*gjz1raWAB|p4N6GG49*g166mrgS_BEef_{b@chRMvo zAb&kY+qdJ_P65D00kl>iPXbb7z!8MC1i{}pA5WNnMbNQ=$p8O$50D=r8gvf zId0xwH)WfCYw!#6b=e#poaKRZyJYjg22{WiYWr=Ph|6|XHhfr-HnI1PJ3Xe9ED^Jz zWMu_#@7oATN8^v+KJ~J7cq49%0O+ zR{`KY5BGo<3t2)i155GB%|d8Z58E~`${1Fwt7^AU0cOz)A)@5Mp!`&(&&(Q+M}DSv zm9~b|7>zL0pf`D-mKmt0Iw&)v!p6pNyuI6j$B6M)RzuQfB}jBdCrLHAhGeB;w-wk^ zzzoEu_a-g>)2OB+=vrNO!(@X{_DTb9$D z5Dxj7wx@ldV)B8zN4-dliu&ym~yo<{b`ye)A2)8hnD^{)FEJr4!p z6JhUA&-J?uA%@kqQ@B(n7UgZ1jm0$)lwRwjh0l0`(?A8@Fk$n-xUn}tVm$PP*3eW4 z9)m&6gnSp~5tslC%E1K^o`NBd6WFGJt-7>bo)*LE=OetAQ|*3HZ3r?;gT;g4zwq0X z0IoaW?65Ru#GM3VBzj;)Yd`ltCcA4Eg0YVnk(pH{lm)ZP4uH3SoAm0OO*dxSWU6^6gR0$e@!!2`2ROM1nHnwBMtM0He2frz zg7L>z6aYJ%`m|gC#ytDg!;X@A>aj9di@~`IUEk8$@mFh53<8lEh=y>M)qno_ntFlc zN2#&M##r&hXZrXSj8q{2XbEwK9Iik5(Efse+=NUUg{W~@u(d4F&R4%ttPH9hu|i~- zBy$7L4!2A-j4IxWV5av5!}mVDFJObX5n({r8%(fqWYvrxrTxcOyi%Ck>t^(6xoA88 z6}?rz5SvF$muzU8YE>wG@DR*BH~grM}O}XnAOdecE+G{OW+c!)Uu^fdWJR>w8GF781Rz{L8d0)j(=nj zpR`Bvqi&TmasuKKH=RU*nZtde4ENr9!T?^1&D&ZX90Pm@Uo{97NYlNU5=Ma(69w=u5C<%Ac4U zuHh81gyDNj#N@OTzQqk?%L6;6Kz6Vu6mB)oqp4uZp zRvB9YjfQzW^GQqZ&nSY`;YtVfueDx!TBlEAJ37Z?=n3|O5g&-PmVj!lDEJt(d; z`rJd}aMD_BNMgi;R*3orMxh>okc5k%RFhT<4gVo!pru4j+and&*4k^^mZ;aF|US7%c$*OgkZ9V1*r2($-F|ab_BC&>BIF}T=0`aiw0pV@7 zqo9dh5->VhS%`IaY%SoK{gj4JuhUBT)(AQLz`2DPlLZiY=&*+s&QJ3C^`hcUpokT^ zdWKyWlLoNbv7ZvuN|;#VmBHL+BHkLm?S}4g^PqsHljU^O!oyd`?gipN8!N%^qiExb zSmV{^0FS91QI{Af{Sl@k^!pZOda_JLvYoBw&zC5WemgCtLFmh2+A2)_bQH*gQL^$x zD(1j(TQ5(YB=*0{!v-_NyusUa$m>dUT1eeF|9>r6mK3tlbIq#l;xIkE>#+R_DOaOY z&S#)A=U@UqUF6FEh{#IWtlxKGP@pm$-QVo~8bhn( z+y^S3Wtm98a?=&jk%vI=Uo*^7$(^085k6G`}q** z+_VM#5_P@Hk9m|12n7^T>%ALL%)S1I>Ue)dqZ9zzAz)#RZO3q@37&u}U`G~ctqupD z%Z#MN@m$zeG;eD~g1UGQu}g_3yD(;Wzd!|bANlP_xtw@Nes*A^S}K1R0uFPcGI5n- zF8RW=F-n)+Yfyw{a@1)zdO3O;iHx?m9qd4Pz~BoGL=e^WDzza&sAD z+f%>hW3V)EGG<()HZ)rujv3F%zhu@3>DA^|e3 zN6g)>j2$JwOq~sTz_l0+AIz4Sy-LaeEj6m;$XnOe2Unf|;k^;28&i~pOqlv)#?T1$ z3eWm`9LYN9{1N;wRzBxx(LbFYUN;ztZA{yYASw-0e?J^3ni?Eo-?I!r_a z04{s=^obZoFDG$801L$seM}TgBDNTBRV;m=fEB_*4yJ4vre%uL+#b&?KV5S?ePv$Z znF2i0;H6<&o0Y^hWN(UFx&-kkDoF6 z^5S<81e2q{XPU#w)5zY=a=`f2bU}x=-0(@OZX%Y0Tv&n^k>`74KaiE4Cw* z+Sl}F|M~6DbPaJMh-qyXzQ$CmXNpg)7GNu!IK~`WB0t{<%yK#d@ zyL{>;dzL|;-HPX*#yicwW9fZY-@`Yn`VfHKl!9mEU#er>p;Z>-yc`_Qjrrt=9sB6_^;* z8L&ghXs|YBNcfNDVkNdKRCpS`cu(0-HG%L@4r7GBK=Qti_72_yCh3sYfHMTZcPmT? ziQ-E;9{lnK7&$hMJAwZZ%4fUs4#Kwg-97lvSDed;3@?n36M;_X=2^+(5VG$6ftUVu zYFiA|78*DB$VV|>@@J~*IX*ix!q#7}Nt|s~!vIdoW3cyIG;mD3ujz0?j&Vk{^B4rA zREu?y}V61E*#)Fh9G-SOcU!84hLjl*5*Y+J`4{8_7;)pPG z@|D_Lx=U*%9ENN(q|Sc!lfc-$=T7w2jRIa!Gph-5cI}GqmGNKT0_RAYE?^hiG~W!8 zS@XKhe_n6A8y}pV>9#x9;XFPK(G42`WSjugWx@T1cR$FVoh&7nf;Vg6gW?;LAYw7} zrTRvJflmbv*NcM*)r=!UoM8BUd8;Z4|)53u|sqXt_hyxwXrwweKgJjJV5z@jQS4yKcJJ?IaZj z4$jy*Mo4+Jw;rTO3SgiOEZRdFr|9nYDjSt_5tl5rG{GLgH4{0uyKMjHTSjIq@(e>1w#&;;Il2BK5vdRj$-Nf`Ya`w8DO=j3 z_O++uZ;e2*|2`&Cc%N8cJwq_(;B3fdbdv-EHJ*r00+Y^kWS?-P@rZ$ zal-eKe6=-z4rKx3cn*6ILss!O^fv*!gM#GkLmUh`Gj%`+Z3*o$2~Q#DBwvS6t|P$9 zr$91qjUyWqOWOegJb$W=x=2C{0)q z6H^Gto%}%5xwvqaH&;mk_51@#__s~wK(@0E*x1y+(&QS?V1;1s@3t;5uF<6TW;UF= zquLr%1ne0WfXs_XJx#;7H>P+fwp1r3!&fbJG(E!8y!-j7ei8zR#tKOEC5(ncuMCp5 zFP{+Pxj8v}dbnj=cFjZtw9^!Xl(!tFIef^A5k`Ci#7JN8AnyZs9v2vD6(S75G{^A7 z7cfl(m<}{k!bkBhsmwz#VW3{A(bN+aZkv&OjsJa|->dC3{0^t=)Q-3|Yev*w3T-dzm&!N#MrAf^$lARuQu?rQ#n=wMOOoXYNB zd|dk_1pAZmD8LkFG+II9WM4zp?~F*V_C+tdX_~(8{+!D<7B2nf98^ z0srkqnkfD28>{J%;ZztF;7lN?AwGQY(H(y@FL?WU7Qk8V#?=pidY}-JLMsn)3UKxf zUM?k@w-&6M7$@FI8A}pek;@^3P?>tZNsrMYO&Y(Fcke%2$KhnhAdZE;QJ}2!Jy@ss zxt~EE@&Uk{f(UsB7a~Fkf3#6HTRLD9xYY`fc{c)CVyBDq46=}QAiJWEUGOEQU64|r z0)g3DXexvT*eVBb>l|svcm8_okbrCzQCr*sA_ngfH4#IQO~0YZS_EFE1hWLsU%Iz@ zaWsqEX+v*E@H;-j#6$mU`|nUQ^MHdyKgz-N;_|Fu8ZBI$4?wYpg-h;fgfIpkcLbho zi5R_yqa8*7m z&CVug(WD0o?^QDir^8tu%ya^oGgoq2u7<_8ORha8tbYoU6cVoYe}EZ;$(9nXqQ4v0 zI@VPT%*)+{M2QSZdNf<%hspZoZ&P|1&_8 zRAnTEPD21fPylIBsGZ*kO--Jo0RK#&KjnTWCLJQNUH{P($PKrQIA1Bp9mrrw5vpYd zX>3#N{NWWuP5Pa?9KPaM4AMx^aF)M^o&p$dX29XpI2>l&G#5(bcrcxDEhG)7gX0H1 zzq5h>GvBp4A~otqQuhQ4N-91RZfB3_84n20JhL_i+gR>;%13Ha2cz0$?2mRX0(8;> zTyqoks)Qy?MrZ`>EkGRMp6x-u2`t&u>lYH*v;{Bo=?NlfYXym`!!9cGNz6`yt=P5l zzQaqvG6kfRF!y+L+(im&_3cMiMluFY4oV%F6oY7N)VAZ-U27j|bdX;LzQ@2H(%R~V4D;&s@l9+s$prGsZ_O99jx|g%OKH$J#h2=jXJt8qKbX{?{_aszGo|#bSy62ltG`wHFXOaKk@@y2leHl80kBs!nOH>*>ZAq z(T;JD$tX$J$da7O>?4Z&+C`lcl5|~hF!buvYvm%tubN00Avyh8~{HqTVkWvM&rfJ z*Tqlx+lbOm^gHew4>yv~i`V1XIJH2=-rdtSiSLtChhJ24JB{SU#L!EO2Xs50A*#nB zZNdRDRyZU7+d8IQJBhu=boHm+C@~nW#l;(y-eQ+$dH`Z5?1cEPv5V_1fa29N4$(T- zTOufQgkBtHjxGrLAG>Z$R(>?U*{sK&QYfM!Wr5l0k^(_<$Nj37=kwtpARcZk!fDvO zKPr&Vz)TU|0TM(J`gQ?rd~Aq15Um{ojj6Zhyh%zc%(e0S{IkSPv%nUbil_D1YGbCQ z$=r0!Y1xOFBW3MU6n7*}`t%#Uwx#h$u|F}Z*l*FqtmABAe?HDHwfeQOP<=l@VC;uXLWblrww8skDwM8A9U}Kup_lMv+b$R{? z1M8R?>u{O71;?%*847`u6mJ$^Im4uZ!DizUe3(?x)Wzl~a*2N!%_F$To7{uRvVT;H z^Ip-3PmK0GucD|;h~Vyhs1Slld&5^hMB5@IH>&z~9_a8%oGd160k|iLeLqZVw5+o1 zwr>zzBw4N|r(nXK$yR-cdiOX^9v+`Ed4iyA2(P**6FP+2g=Uz-M{z z83QeK!wi?bG5&}ic=Ss?3bV1k-OiUhyFqDizFCRM zo#Y;oS^tIYp|?KoUJmA0EDaUEPj)Pa+JKNEFUVhOXA11+{FKpKtF77zCHXM_3h)(@ z*ohKD0l&ErydMw?-B@xurk`psbH9f|1}3t=76qmM<>HEh*)G4wzrnt8wVqM1*4JG< zLY5eK5Fy#(F4Lk@*3-&vhH80IsZqhy$XC9uGa!pKCuY*{;L)-BOb|L?Bwpy?TcJJV z8u9&W$^GrQLsQ%LwMBn07W-p%p*I2R{mnRHMsbyb#b1jQSz=f{jl&*jIO?=aCI`!J z7qtBRqQG?emkAr8EyuyeM4bs6O>3FVmlJo8oG=18aj;2z3ewc6WvC-(TcRchiHH6L zn@u?k(&18S@DCsSNErk6hY61e^}I8pM)VC#(kIO37rE|&*XFqz3I4cAxg0%Qb+M(J z>B6wIGos?lQx!R;HK5>6>omOrAA(5kCE!eTxK#gxz4r`?vhBJB73Bdzk_spah!P}+ zCJ54iARt*JNmh_1gJj8yl2H@{nk+ekO3o@EB8}vnL?q|fWM<#b^L_8Ub1~J;^qd{X zq#jz7P3FIJ%iiBSy}MN2NEUg?Jb|i*N8Er%hxm-^&P*FZ!_&>rhI-(7oTioFEi;{T z>@_MZZW@>E+f#_mUd787td>>T=j#+1O~_J@1+pD>*u0CI=)k+qiEXNBwwR@!QF1;` zT<+I+k24eS-g6c?l`5%LG4@G=B33%-2Vxfh`Ew&)dqJq*?RM>piASm2`elM`syWZ% zbRFS!hJQ%(_m%2sQ}p$Nz|J-FuOmdNn15arwfFp;t%X{Fb^x3ngs@_%-;h)&z?>Na zy^y(I;pGn6ksm{v0aK&>it*tE{9Zmo5{2Xe-x5Pc?r9YIx-vd^SL%0o0R!LGOaD*| zRgd$s&&5pRtfUVRn|}J@_HrjCeIh7TR8x+ZA>8-a<{7bHdQ8eTbTuY;Ipu!%o6M5_ z)AUiXFFlERtFz}edWSRbO$Yi}{XUOYQjN^*Nx$PwyIKFJl0!N7MnZ16pjB#A_GC}G z>QI(&VQr?1Y5fi|NoD_!aQ-(`uHwU;6mHM=p6(>@0(5qWRIm?s5-ELN3WdVV$(S zyu*`32YWw^(&|XX`eCS)E;yQ%Ia4ylQR=W%`Ln(1D-tM2 z&mcGy*L~UxMIC3ha zOf}+GxtMZn$1Xm5#9{m`oY9_+hL%U`3?}ZhOm|71MTjrqmBUU&7N>7_&^WyJ*XK z<}(GktFAyvl_Mwd*L57BgR$rI?vBAzSclG+;obj1!;7%tmqmrDCeT(F6$-S2V5K5h z_}qJfqMrv;^yHYm&SO9@l__3ub+wMx3=Z>vWE^Gcex|m8gRF?Ay38+uK&ttR>g^x~ z@{vj^DV=As1w_YFFFY^%zCc*$qvSu|%@j=VMC^`2(B7~G1VjbPZ5 z<&nxS{N+PdoFb?XLn6748Q83DaqFy@K9!Emww&;ztrHUbTW;t6*~21|(T1k3Z(NX*uAT4fj2I-aBC`ukzt{ zd5idKpNI13hk9Rd;|(+TaF16TLuo%-*1rPfmI@U$|FvlKO0A}jJVXI0l+0yVm!1P4 zZ-wM_&$XVZpnUJ>XcHd3sRJ;>sjOg}NSS)tTfeT(i7-*Jw8y5@s`@F!`kvFWe#K4+z-#Lg$Kr^vQ% zcYSEaMW{ute$+B2RHZ(As&x;y9rSEfLd)H_cg0U!uq6od4yP>;ety}%t<2jcy2)j! zH|NxF9~6yp4aEXV(RgRwGnsm9nsy=i*)eGugQ6n4#B;BMryON@tXP)nCIR0&=6MA^ zSB31yMpSPt3F5C#+6+Gqp!8=UtNz=LgA0sP=%YnbDAk&&XMB1XJs2;1xrZprf8Te2; z!S*g6=TBG8DLwO}AO^r&3*McKE1PM?C|d2QdXZSB#=xtC>o1J7yy>Z zU3=(xxCHU zG&Hjy?_5ZEO~jvErFuD3IySoZ4?{8Ha*zwVI6+?8!Wv&;ZnZm^n}x$a%ZX{1oNT2q z?QyO0wM*>DFY}Q;*G7_kM#igC@F4E>c#I9j+rIQ`NA8#*Rg^BrvzJOd=h6DMUFiz9 z+Y`52&CW^K*%G4AyejRS8bzrg^Ot>|me214F-jD!yy}AMcl*J}dqCG;8rc42|MA}# z`T0Mqj~F96Xj9llg4t6t+bODU@)VlVaq`pMEp_iIx@jz#>f}#p|D=&hJ{?`Ge6khq zwQIYnryH)DrJCr+M?-Efq;pP z@hd*nK!%U=2gXSxVSxeVr|VFbY^B1-IabEm8~4xn{$3KaQEQmEpr{$Gi@DVOMr!=} zWO*JJcFspY(&JZx)6b7!t1lNas)<)8 zjU5a+b4w?%719!5vRc<$<(BF0Vxbqfe}r(nc4&BD>!9{@`1 zhA#{0@a+S4I@)-}hX{RhXk30e25^QC57$n6Y^{|Z!?LbEs98%=yA(Z&7SO1RC}efFVx_XXOSs3^69GnlFFz9z?v-94&A z&X+H_1#^Ouh%&GK3H8Xnkm=PzjmuPyH6bCWL%cj?Lsoh4E-ql(|0ZD$r zeLTTHTwv=2HM91wd>R*$KHyRxy^dQpV&WU-ld~-{`bZ`+!Z)Brf$RHtxbX#wr~~tX zlldY%IEIT8YDLq0E+WOBm==J_Yfu7*RNRwW8ySs@9Q-5ay=Z%kh^{=5x>o?^=PK9k z$2@>dgmeGAl73P#!#6w)i>J)#WBv!^u=a(+T1CB(2}^BOOa96+hU+Uuoc2olDGKp7F7-J7+(2LLA(K#U3{d%>_OB}!KFpLSo@FF6)8x}=fmmV= z3#V$_ADon#=m-0$p*)DV8S;?Ej%%#{0fapj`F{(0uqlQ0^{-BjdiOtX zZ>tEZ)MQ2!*|j9J-NV1i!QaTW=pscw+XJohNxa3)gaBGQ5Y`dt3Go)ZFx;vHptS4+ zy7h}kZ<-lA^LL39i7p>+Oh@b6eMDRbg83f-?Z)PfnxmNq+xaTMk4QM7$O)JPVMsjt z4h3k9K+&_Lc+UW4qQe=<;~c&Q(}6}Jl*+SIU)UQj+jHjjg~T;v(H=){PfXZat=Cn- z<`6e(ck6D*(sB+@{F=@1g7QdJtCWW%0nBl2mF@=pNgL_;TtTo+;u8PaCO^2bmfu5; z!VTN2JKIM5xvJJikKa6S8+BO%4!+3IMQ~BxHY?>CqQI~FIN03K@C^_0xn~O|?Js02 zz36dNyZB_G5}s>zqR9V7RsEeZjw)kW$*!-ml2V&GtKf03I78qZ?ti4-dFts_ZPy`H z6t$nOnRLGVRY?Lmj_l()t8X%rm*jn3CnTpmCdKTE3dlpR4QFGM}0>HCEyn1X%AIbJsNBM+(Paz#kh3sZ59BpABP z)CrEd#ror;Ort#Ms3(+6*x1YS=}9-Jw@%mX{0_~`$=gsDOqY`hbS?rWuSIK}3iq{n z9HG0Rns5ZXLq)Z$~1K-R;e)Qix32#_KDAyH{q%}seR>5rCC zr-WKoBNZR3+ndHGrTIuLp1zRMC7rGJuA<-(-<8*W@nnq{Iidxmq#4g(n8NNq)wcsl z#a50lv|i}KJ1Z2{zv%AC4WQu5!JN+VAYZp}Go0iL*TV@i0yD$_w=g~PI}bWEhRXdj zL3E5bK`uKaU!)UUR6M?ZuhaNot6?3;IRP@>Zkd}P*8b`??6kQdXqVFg&?Pg%w?_~j;1wk1`}WyZ%bVDx&|A(re#)co^+B|; zkGu82hZNho-Dyj7klr!m+!gHkTOB&R`rjU*=-nO#3cz*BsRNGZ+$9Ew3}*7IYMn>- zR7I@&l&dRKJ!`iir91BQp4l8FjhQDmJB!Eqho|9lUnD(GUx^O!XcfGbz|~+op&k9D zesi1{+$QNz;?afPc`1eTDbe^d-hR5+ms|07A_WHCqL`GTDjPA6f!&7^-+zRdw0+?8 z`lZ+4yM`xaQ;B;FiBaZdzv-A=9dzrTcN(e=?aw+7?@gq7)@{ld%c4oez*C$s!D>mI ztg!%e*Suet-&oGY;3 z?9ezV*Y2loAIcz@`yuBEHIE0+tDjNa#CuB@LVn&_5f>3x>!;%(so#54_&GDedYxj4 zGX}&$?CVQBBnnp5w_p5KdG(4>7T#kg7xR~9XBo*?FgSidNZhBGH+}5HmT^yOe}ekr zTpzy3cw9q4{{&etzU~IJd0X66;Y4=Y zYe(N+e99MNtVhI!2M5aT^Mp4KH@gj2SBLM^+I?)IaRsWeD(O=FE04JYRO%*WUF@CBP5Z8&Q6_&=18>=<~gYF5rWg=g8vzt zPlPC6roE5jP<9~FqoUEa0=aBqR#TCV+5nc{^gtf}eiA&TO<^aY9jQ|11zG zs>aTfo!Vs4;oBI_j!`>#=NL)xx9g7&c?*ctCvHprX^>5kBacgIkC}?r|4A<+a}lCC z+VgiXn_o)W5UV~ybVYOP#~rB+)Aq{L2Ij^D=x}`4NoDwmNaPnjdAFQZfdt+`M&2xc z=}(GjwgkytsMsn(!xa0t>9YwjYq@l+-Y4WCW7JX$uQ`144dLcY-;M&LF%_hwf-DG(c&4WqjP%xoDRhBbJRhrDdNHGIZ}tGy|&nOFiWc zPgrz=zJw>TL9(PFyYtVvss2Sdg;l(^JfMU9$&H8EH=OC8{(63!_n8N_=kobFV|mF> zY7)DTO9T~hIP?y{hpJrT$WjvJt^J1iFddBp5pQRtIT*|)qF0A z3>~%e*X}4C8k{r3VYad7Qt4sNQmlCrKl)gCMspCD=Wd@J>p>`XSo|Hz+o!MN93F`| zb#^>Hi!Xg)^ENXIo8u;1=cwJur$S#2sARbqnw430XI3T&_+BAo_vhM@%6%Nb%6)70 z{fkBL7mzK_b=Qg2z0T=KrOZra3J|LcaLt`Hl29=(ns^Rhd-hsC)7LC1tqMAq+^zK{ zS>~8{sNL-`cXO!xUZn5rL0$H!AlC!gM~E!Wf_1+FJJbmVE7td9#epJ{mEHL#D}!AD z3&xCI%=x>AO4YG7xK0EK>8ZC)+_P7NyIb3H5ox zkqx~PXttJy`wI%emR`H|?eelWhEKy@VW;BgY20F(TC&7)8fz9Y1=Z&8tk%g63N3Mf zzAD|Ffl4lZgCJtW^mWrpsO}^QZ7Ev@rSZnSyMqp_y8{iFX5x5^@mae`a%7@4aI73K zjauJ?Wubx6*)x(&G@ejrK?z!Rg`Xn7`MIEVn-{@tnw=;YB+C*>5E?G?(VZ4ur^h)b z#q}udv#?+hQ=#3bOT7xk4}T`1;~7wo?m^4q!`tfkhV?6`y0gTOq4Lzst@Hjr2}ui3 zW_c+A1aeV0#@{5j<1RAi&WNu;j+>$x`@42rqsqM)p&%JW@npH7)UF?v2J(~AhPpJ7?q)q_!3OYQHg z-cPEGze_qPp?!R!B}d(6%-wOAa}fBQ)fz@Fa9G_Jyq!~Hx?R}rlE_aZ;fo0uc0#TEKqXUD4ky;e4$j~m z-P&KNtdHvnq<2y7^w%Gm9c?5ZgA>%S_h6Fo_ygVrNSKRj2m@r*%*`=D|{~*31NNk^>6FlZ~+JKOo`!vts zHe|`>gjlYe5Zb*G{1x--%^(eEKX~LnJA&B1qxGzEW_N*6mq2 zf!Q+d&lXr|Dn&tAZUX`KT&=inGh%O@1_R5D`a!ne8TBVKj`!=2QP<^PC;ZBww~R5$ zuU~@t2>B@kpWP`8Jmjm_m(l8@zWc-DHri>f&YM2q5VI`?F+*OfvOW}wdRWACqS&C~ z#X-w1YX7BF_vyOBZT$^Ey7A)&(<0mTna1D!DYJ>6vu7oADT67Kx^npS=rnVXZu7IL z!}*^@f1? zmUL6~noLNnwU>dIkk@^5hDY%t2Y;(o%zC7W>ksxU=*&6XR$DS*n%Af-qqo1cpu;wE zxQ`c^chF94&S&ybm3|Kn{Nf+}3740zo^0fzzi1NuGxLpTA{ELzh^u_WaT*riM|{4+ z8hFOvED_#)@t*fPohz-H*@a?K-kOfH4B|Jd+T>~td5Y2e-3Jvr2ZAjx)4yhkq-wN~MjjT4>5UJt^$WEzr7dp)%TeXI>URm-2~ zB+8_5J@NfnYRMK7864-jp`xJcr+g2;2`dRRvD69vN&6E7OXsCgMKR|}z~|a` z7Ao$)Jw{x8{nfr=9Ed?gIPQE#62q3C)ko?Kr0~Mx_B)V@I~ejr?uEXa<<||I)@TE2 zWxHzoNL|gu;KZ>0K^khw(IwAKr?y71ijkYL_%i|NfGPL;)MFwH2vnZ=$d%7wqO$ze z!8~fdP_HE@ zZQq9O@}2+E$q^3b#;RRl9hQXR>@(bTc%syC&i0Vw)>2<~OZLz#O0Bs2urxRQWv_#} z&wZTp@`~?qb<}BvI3?f&YIRM5S;7KDa<8l%Lc;^wvh=5m10R>~?_=L(<}(ySQN~xK zL}t4*;^J1AqLCnTcY$z@a)He(P08K9N5&(Kk}bWG!{>{Cmb41p?(%>QP$`4z)zpYZ ztZD1MfXDOiAS8vlk9-rC$D<$TTV@a=E~q~7Rh*Q?Vppz6z5j$P)_|nT_7NvcqRC14 zmCQB1Cj?WW&OG)sOtu0AxXI^{4&aSc)4BiiK%#`|)J#U-D{^B&)SHq`&vMgAvl&;lan(~WbiWF>w zB$t6Vbs?xRNtky%UB#^SgiAmpSGL8Q60&6szqaOZt*uE_(=rCCjA~ZH)IC)ToWeofa8v$g?3c?IDR&P0}nM*5DZ<*D*@l*HRV|)bL%Q>&spu zr!n?&7@nLpit)OGi@0+$KuYM$RZMT#Ew@o|h*yc!|HLa*oL=YCmcT#C8*iBD=*nUY zR@11rE)<;I8o28rq^4A>qQ_BNY#_1o>y&=@&{+di$UO%BIF?_=k4 z?M$OiY43L;1t}fY!>3z_wU;&9mX5m9=0_x!B{0)EkLK0s*}Gkb78bRcoHr~ld;{ax z$lGgruC|;3nbzsAU)q7H^L>P<*o-Xey%?O3^slQ=fsTsE|A0h z7JN$n{591BX!KyK7HE_~MLbj|bNyTu2NTBjPwDy+NWB*lp?AKc*7S|y7ixm*cSwV4 zL1b~!?@p)gCgBf_Dey0XKPs`9^%M`Efyz*LFRH%}yW2RDOJx?7UfxV={O>+uHA8x> z8uK5lrIzkqdmp7;`aL2dfLRg@tceWQ#zJn%;D&BkT};!aa770VDVhUxv#Wj|*$%bP zrX?2dwugFF@3!H8D$-M-R@j!f2A?2GV7}{S@C&)(_+552YA=mta}c2{dv4vh4gcW$sB(g^H*fe&XumACPN# zp-rM0T>gO|l*Q5kWH7t~^Lnqay%WDq5csn`!|qZTTkwaDzolsak~!IZvr|5;yM^00 z^=wi}0>rr27ygcMR~qx*Jd(kMTq>*5zK(IZdDDXGWC6iG(76L$JxWx^x4LsH>DEP- zO1g)79|0U`2|!=skmFd6Yci54z97LY^emmE$RD;yp2 zkQpw>=CO51F`~7#O_^z99Zf6iehPf1?d7Hn^Y*07G?UEU6Q$CNFgoUeKP{`t3NZQJ z)ZGy@-5o!kE7GaXEGwZ!&;7NeF}IT=9wS(C&!!i{uYT5f1%=~#QO)d;fNhc#bXFf{ zk5F(rj?k~1jl&F2{Xhej!T|+k7fQ=nDwI^RrSOR;FHPpsBpS2!Y0Kc)_;9;4JFS26x)FPmuNKJ=D~9r;)Cq%{)l2`H{BNs(T+*t*H$eJT2V zg749;Yq*?k`9#EHIi=u*P8M(Vz9YY(6*Wg1H4bY43@D`KN`uzkYw`8FPEcL0_wh}I z1@Qpb>FfgOzO}x6YGJIn#F@RF-G7^tgLP)z26QAjr^piSyW$E%JwKmzmBwvMMy$Ie z-bbAOCS-5`l_f^9ehgt#Xh8fOE8IR3y9WpZL&WIy;gX15LR9?II+|;hU@A-1SSt9D zT`L?eHlKZ_Vzx=|P)!_4PKKY_@iwFV#@# zghTu?w}!Vj;b-x+*muokob$;-=TI%*!6S;9Y3embPjn*XG=&wvL(X`R=@`0b4Nqq2 z-1X?ypS;r+l{zLF{PCr>()KtQ;dt^UCP8wyb@t_9IrbS^xb0Zv5U%IG29_Y#z|ORE zEqr=9L-p%2rQhO@zfvU^zlNbPzNNOJD50mAr^XZM6IU-SVU3&ujQ{ zJCvBY{(+L%rr86T<;^CehV#Aq8^2yq3^9e|x*qJTVcLZH1s&e%LG2q3xHr2%v0QVN zc+u+gv0}-?IkhrzHy$3$a)PkEF%+GK)io+!`yI+wVZAw!CtRX>KRu}sl3XFZHZ^xR z{NIbhfNu4$7A$8i2=;VsC6uyZmR&sF<=-XlRody0p^%M86AzZD=~tS0$7}zL0y+3XK$i)DK_Ux!Oc0J z&mG5X^MowBEYGkR8?v~r!3zRJ{G5*m;4}*;DC~s;j+|WY&eh!}y{386Jndqq4SQxm zJD<1dme6nEQ);TU%MsA-0`WGVg2~g;d6OZ288gd7uzNp>_F`v&CrQ&f&vI3fj?Vq- z8MXo5#parD(^V3I~Qq%CpOBmE8&<$=BG5m-=C-7^2zC+11RYx zJ2Z2SC#DBg4ux-W8<{amMEFIP5owE9^1@577_H)RB*Z+X<~=k$;v=NV#5Cv68~*Y zTHgiLezgC|&xqU_pk4}|RL%=A-%IVjp-=)v5sYS*JaG#tL7}mAv(^^rkpg%EZi5v6 zbI`Vf&6C6SBKGt$SRG6IEqLD16x`)0AEV;$PBMpcpxjz+Guw!sY|2TY7JcqU4AK*T zS&+w)d=wLJJ58P@e+~TPNW#tx1G*0Wq z2nq=MCqqI|$iJh27c^9NeLnVmDEsrK=&6>Tg>>rsblxL6fj%QW$n#Q}+~&7Bf72;C z@N4i!a&}|B9qEFv%y#9CYECn&sE*1^@2!5Q)kK#nIAtD`jN`b)&SRI zx7Aq5Am#ST^IZp0ueaP`1BKA8Z_jUKmc~T*N*2%*|149o{TYf~f^I^tx6hX<$lQej z92Yc;J2PyBhX2)8*h8w#{N8GN9kkX(XZK&p*zN+dhq3KsWKW{kddv2@c1Y~>X9Z>h zD`$#=b0~#~%OROYLR*FGmg-~$ezOrY!kv_jCGS`)igF1Uv^TLBAHmKxQ^KxV1AUn-WVq}ONcwB5rw8&Fy$j>p>A=OM|}{r&Z8nE4h> zgLfr_FFgVke2^*fLV(&IsjC-Hk|lN!75j9$Y;gH$smfB$GM|iYU16>uui@Hl+Dkq@ zFrVj>BdhI>acPU3SMIvPO%|(Rm4UTP=I6e>5~0Q)c|!4Y6v7E2y}27z9a5|6N^xZdz1lhjZ%!uS8+>nn1Tp>OUBoCpCiYzzt zn*+aruc_?wzNaIM^GoNR=-pRScYKhzkf`9Pa!?a+WEpiQBQ<1D?y~LUn3#TaW0Nwe z=FK@xqdxIeL4J+cwTTov5&z$(hlXar_^Lwvp?i6^9km$;NztK8+AX zpO1EGxDqxEuiM1l&-;IbCMuYnFaDGH?bUQ;lW0b;uGdMbf`>UN{zG4{CXHM{EmhKt zuio-`mEDonc|!OnqoE#UBCt7QqBjbvWW>2uRg=N5aTZql1R4=@SuOo?ZN; zKF;9!r=g66-zJ&0e@b=~w5_J@$<_K=-Xd&^)aVkg1vih4->c8XLw9_dk%z+}A>!}# z$A-c2lLjP&=mGI$eX=p^8q8vGqq5T*C=6iGp9c;ABO=zP=tQq#b{@s8Tg1Q-^P4oW z!fCBpkY9=CahC;)A{$08>SL{0KU-!q*O>NJ+|s93p3WS|=|Pwl{l>s{>?0i<%VA$+ z_#-@Y5Rm!f5lr_FB_-H*ZOx026I257PR%WGdFgRS;pp!G_OGG)pQiBVxnfeDiuZP} z!)R(cXn&Rb(WX$Ij#0sF%6hf1#N-bf=v}t5W-OCqSHUqG4prl}?kUvtbf;1C87hXv z(wN3NcPSXUZVssP?*j-4quIO5YRgy1z!p<-P(8dU3L`0l6YJ(F6_TjY+*m_ed7u{Z zi>ro#_9Wp)9PvwKlxw>_ah%z`sM0TjEt1HV;Q#L}@vAW`CG5)^4RumT2DzVM)$go_ zx6Hy74b-G^doWUW?b^{+CxrPfYnbWAj=#IumfA9CMWIod| zW~&$6&JsKiQOpARW_r2(ASdema4$wsSq*K+W0~Fn9)(9DZ~8=G=s6G~Ph{x*KENl} z82NB|Dirr-iYx~+2709lgGWSKQ`O*!vq0UOyX!0R8#yH;kF@W{kA7v{^9t!lSmoG^ zXuTgaorA7(O-P;kAe5x1W@h)Rtv%V8M0ypaxqGY?oB*P3PhHxx(NgIJI3@?FZmwQ@4k+D7hERSUNu=*|s!ZM3EJ1OI(c#>1^u^z>Ud zKoa`|3s{oB`%CfSJZ@Ftg8U0M)tcorc~p@*<0a8;#?GCxOXD*@p*@D~kd{d8WOfcc zb@8k}yBc#c((@JgI@|k&ze*?&`dtea0GTKyv78j6*P(%iGvl+3w>?W?WR|eXSDe5XFLI0JuXD=E^Kus+`H7@m#?b{4?doTK~bx8 z{MtQFh4iIr1EZYqZQeIDf2s4n4TzYNPx+Iizs5UqE@)@Wa4k-7{KwWn_RJ$h%rPIH zng5~>dV1Q1p44>-Hl<6zjR^u(T-I;F`w8ejfhwbO>19>k)y$V$4u?jX^j>we(JKx| zD(0VM23Oh`RI*%(65#DKz5QU4oJa{><|6 z^pRnmvs8;LJtybeu9ShUPrG^`S zrjtDF(zM8`#Z;85OQb@yfA)CKB~4>Z$l7syXSOFMX- z!l#ZRgpeOn3qBFNOh5_koMFIEd}TU)wL=i#kENyo@k!s)lqg>t_DjgecVRT|4)qW| zcZA9KL9Mo&`5uPQ@2}SwnG*z*cVQUMN!@&T;&dGFcS5g1NSS_xpD>vY=Vn1ZCRTtj zq7Ywo^&Ssgrt){o=!JLi3>}|4ZIA~&1HU974urdO!!Dn22-9?B~0E_2%DS6J-FTNQ}V@3Cr%PG!!OTVy&`>rKn3KpIJqW&%2oLj{2R^D z5y;0cA#LzaS23i#K?{Rou=dA}L=m3B;+KW+>8sEm1g?GZr3|sudrP=xcXkCO0Yd5? z4cr$%K7Ipl^QI(+lwb;$U=(N4h{|nv6+*HrZ2>1vLvxmASe9o`h))s=f?8!wM@=jJ z8SpZWQC|`Hm=fe1nh0T++wTqFGLB7=)$BLm8Qwon8>D=8;`BNANDp_HVoML_(5XE>(TndcKFX~_|Mk=&({Bc z7U!Rs^g&lAl3c6Dgsjh1V1s63)dw$iy&E6fQIL(Xr(>_--DAhwF01Ul@c&Ejh}#v)cXVk(tV4n z=C}xu?%~O_N(4yU2k6ND@FsxT$AI1hs2on7J^)a9fXe*qZx{?8Gu_Z2I{gFCG^e>i zWge0jkeS4=+Oy(Eb0-t^F$>Lf1kH4XRRju}W4%gp7qwY|~Y*%J!H+@|=ZAb<5Zhs^+L-?$_RFpSP!Yf+809PBguT|iQ z(?jqYwp_XEXZc$d(y59QPWxe`-iN}#8eTpDf4Hl03sMj{0K}&1QRYFqs@Gm&r5t;2H5;?dE2VCy?`%9F>@NT4M z#nZ$ZxlU06zJDeH8H`AYKl?@Gp(y_urT;wCe@5y*YwAC{?|<)*kXwa)&*icR14QL(!IzP*(9_|PBC zKWT%C6{VdYjBY^@3j$Xo^z8me4LFbpj9mC{+0HOc2iJ>7TzbW@(Pr5<;EFEZeI|)* z$;5JP?9~`QnH24hecNmZw7T`0dt1jo?|c`l{kW;d#w1>HK=>}MZ^-VD$5xG+LmszI zZPu7C!BBL(q~$c1(twjp@~M=#?jYD|P#i14wrs0C^0@bostW~$etA8WWX6>6gU&1b z^E>ljfBlRHUP;J5r-zmaBYK9{q%Q$m`Gk9CShg)ihe8^@oPJD|VQ zn_e$Ki}rmLYq=d;y% z-hL*;%8Uuq8{)>fOv7(Y*kls(98P4PAl+K5OR^g|s<)(`-`MvkYByEbW_oyN_1j}T zhwfe+lMcCJ5>L{n^5?{^nywuYv}7ox#}j zms8VQTGeM(+eT=dG$)r8jyV$BE^*LPv+-ki+W7dOk-VYq@rVVg?QFO5YFuuT6fT!; z_vg5XNuRr`nLCR~bmZM@7+aG*>)>5n$NqdhWzF^?=k!lbJ0ZN0le}T0YBk;qwQ`@d zCy}%33x3?ko}psdZo|RWXH@K#tpz4IOFl-Jk)Jdi{nfMJ;zv>jx6-|L1SmVPJ8VINji^P7GeNrY!Mn8#{HW;~Z&eijcx{<@ zljL%d33yjCuwTojt(d4!)R_Xxq$t#&jDlRNeHhSbPdxMJgD#M+i<1i z+n9Qf@X#SXb0z06b#f>2_+Tf}eP*g0w=-nTnrz~TY9?@sWgx{_W@6X-*IMV3eDPc5 z)A77@h1^^Mm4;+pP+{~6kIPegjnYsS%ILgCPxmv@;Q3CtwmqXzY ziNWY}LNB#Cey52oIX>Cv;N$YhI}J569nsL_9-8|Vy-w7W z82l4C9L3>@6A6rUUZ19w=|tD<^(akGmP?k{F7h8Ixc0Wb4jD9OJRYvu9kDKr8j>ii z_1UJItZc)f=Y7?~$HSAX2^Gsf-#w@kG;kTY-X7mBu~**tLABsvw3ha##cK@LF}opp zy-hX;q4rprzOk(Bx((yY7s)s!XFj&-2{h`h7&6xE*12!&_TwnV4^k2yNaz^R_Fcz5Kpttv&Pj$@dq%ZXP9=QBP zwtO4&N-}@gPBbxCrkXJN>Un<7+mvkUG?c}`rw4LluEX@lj(vACemYECtxeW5|9ow; zx4vF5hTfsheKV8Dim|KSE4%tY!D{0gjj^|7ar4}%u*b}?Jhu)AoQ7FM;*5_micXJg z>-q!jeINcD*m`Td*0OGIG4JWy?V$#FdfLQp@6A|WPS2VzcJ;-!N8P&pL!y@F@}742F`cq* z2%|B!&DohdlXN^8tAkrT)h>L_0N>PVSTb^4V3Aa};oxRbnF%&dYC)mnJ2HHc3Qo#p zqR!^mE$sx2?T(||L{{qd%De~50#{7>#*U70mHP=+M(!pFokVp?zBg^mHv;uU&D4T) z>#i9b?urjy-V}y4gW2Ml%|AjG+ruA%WX2} zc-=ey`&>eq(xvU5prM|!Z%XS6&4p;1@v&JV(F|_<_|L^BIJ@o2eYVYgZBG*#U+_ad zv&E6iuV8r4HrPtHKT9qvFRZEq@Qhx&{FrTbPpqfaZhx|CDK*N?*rR8E*^Ujb6vY%g z#!Y+FGUB%MlD}ht#l?E%!}CFhd&a0SG47?WtjiC=Tc+Oi7MCm#)R(9+#tpIlDx~s#U7x;O>9P8{ z26|!QJn9Q>t&lHY_Kvya^Lg<6_q0|u31K}RV{}N&6#?9c@2Fu*E8ES!5lQZ!smAJ) zmi1A4LkyCM!xrIfZKQa~^TBb|s{D#lK1#e>s%d1oL#Fs{kUX_Sto z;Hvu+e}irG7-_rjyduiFeQ?P^r`8?}o@;owC1cU?E>dg^HEy1vhw@AiLyp4-y!7KzZjxxrz z7S{Vo#NsxDf-Op<(+hi7`{wQOsNdH5SQFi=X5a-xB5cj73YQ=mwLzMCY&Lg zI*lorj>r7#=EXz$CoOMzs(Zp=BYxF4 zJ7=O&VOI|RXv=l%d!6|Lk;dT-IyKt*n^u1QCVEtXPwCCPUSp&9bLp$u8uLwSY7Adt zGeTphug}Fa>htbJy6?R%OL5ivwA(oRvyA_32|Y~FbW_~J8sIFVFMZhe|ah_Rb8JpY1Z9DT{*Fmbk#!e9{uHu^&(7HqIrMQE4l0$Hy0O zv!E>d(ajd;<*}?dqQd&-q@U+d-j?dT$pQDOCdBtxH0$LS>I}qo3pcFl^LhS|+qRE9 z8(+3EC`$53b45Q|soJ(OD<%Ca=}}MK^!@v|M(waBJlC z`?GDf^|{O5eMgvDLt)pZxO8mr(xdC)LZn3%*jl+T-|SHx&j!oXuZpWco9IyHabeCq zKV>axS1I_hA=sY5wYJh~m8I||r-z4RPFK|4jf6JCsE@3ud)Ycm zpJIyg4sU2l_Nc}iGp_lbO6xycc=#)m(f?i5x_91nlT7@kcl)jH0*)f7m5p>vsgL4# zEDJZ+d?iqEl3DoWPzs6u5pS(w3e3KtbA_&VyvvBvV%l^ReZ^1`?BE}Qj!1xayOp2=X!qk+%NaD zUstlqUgcf;eb@6}`@UoE2m-}!nc0_o{?pIse6|`wA;An^+h2CBcC~#gE9Npn+u!~{Y<_$`MNvavL$Un)RnX>$xazx z2-22S!tu+w9T05ZTmi8z(i!#axT|d@TgHbSu9kb&!ooHM(_>eGTLv_01{q?tLDzi4 z+S*BOAEeEag)!3MJ|KZ$fHP-AE!M!snQ8tklevyd=(fqN)a^Ig>}TPt>;JHD`we>a z+3>EoY|lNJ$MsDMdn@%M)B_x`@H&>#ZAdoVf89SLy!3p^`qWTU$AXV|Z1;SGnhsWP z>QMqnEOpyf9UQpkF&?%g%jgc?GQ}eMixRwz@j2-w=1Y`Vwd$cz7T3YN*{7H`f}tXf z>JPjvU<(h;^a^~%ayPh?DtnAgK@Pi#R6^68}%i81H#F`vCo|f zZ*vlKgW=C6yb^nMKH$ktLBl~cgZ7HLkC{DxgfA9uaHCYFZu6_J7{xio zKcRoFJOQ_Uyo!>5IjF-IR|c`cv98(*uQXiB|8X9wPKA}Lc_cLwts6k81o~yV3F)%; ziKD8Rd{J@SQ^#8)+1FB%%@A0{kh6JBym%^Yy4FBBy}anKwU!a!>#;#QXexSYYB3li z57VjDQMi^;E?KydO^`;4jMbF5U0!e5w@>Qn-nSmCBOQBVr;`Hx#lGd5!`_ueFom;d zS@Ze)ex!Byg_kZ}+K}lg$EaZwXEK1=Vo9Dx;fVf&CEI0+&1c+%uvLIgxy_nU^?@F9 zSr)Zp0iO+iy=?c;HEhBua6v5A6e}I}=}syC4X-D2jcK&D5{~()U6~?xR!pg*6{5%f z^;z<0FIzs_0X%|w>5J|;6p=$DIgvQ(2J0?6O#?z;%ZTV?L1RbAr`8((DydgICRuOi zN^!r}^@`7|rJLLM)oH9!4xasFYIzh9xS>tXnAU~1$sh%OnWCRz z;%xFBT&;Zmq`bTWm`hIb^lj7$I2E@VuDIurBD^La5q5$mjqix2Z=|8(vlzDtX)+sA z20aZ=H(qfoMKvoQhBa>;EP#9c;vR5t)**iQ-LcRFMqH_*btCfXt(|Lu`q1;tXYLC} zO+vFHtweCK{oOVzY*u1dZrc%JM6(GqBE~m*(YPG{Q5{Ar-ztnA^RNkiN)Gl6U z4A; z;)ml&aZUkVwvl01<@FITovpV*aEb)!a4}-Nt|D+n-2<3|(AdQ!W)5rBb_VdLe)dlV zWEPOOyW{;lbdH65Qkg(L=Fr1ej2M@-PU@mq;Cc(6Nx87nOvbE16jm`b&P=a)To-r) zm0Pp*-G5k7^#W<_MRo2DCTjfWQ*hT? zXP{46OeT%b-Qz-eQenf_;i=U5SPk!A8lryaZ#bvuOA2Tvkm)nPEK~ixnI{KVJmQy9 z6O*oesGZ{x=aiL>=z5*caXEehas=yiXUd)e#6RL!2|b13&nMed2ZfB%*w7DvhEvq^ zm}VkxNPk~pZ8}1un&DsjR^5EXgFE_wldc5MjCiB>lUnevSZ75_qmCL}q`vl{3a)j} zKo&T9fcpX%7pS8;*{Z&~KAJ)a>a>?qog++^vTtY*e$p>8H@!Z@&bM$AMq$h2M0mA8tEfMHFR_S@|OSqhMbzJvnJ znq7t^o7N=6BzTjEDNFi8od-jwKgL4mve_jgj2AgRAu3Z7b1L)lFo(fbooKf>r?rX( z*Z95H51q>n$=%4VCgG14VM>>7Y!waSsx7hAC}(_WiH`W9)#C|)`T(o@Nf9V*6TwAft*1iWNfF^s}`LVTcqdIHO}+Fzw3RLRpEVv=>5A| zqOBvzf7+CSmu{aJmleL>v+H+D`i65(bKg>ekEbD*>BhE$Qi5Qg|47S}?DEZ0snF2! ziE5#-Ri|VgydQ}gruIB*)TA2X^opym#5;SvO}!siQ}et_2&Z92aOVI@4-}W@FkabNLTbpO@UhGhULq>PgU49%M^^7EFhN|rXBi^R> zqtjz?bdqzPW7SiIUoNn$Pe(}oJSYx&= z-PV*_sCXj+R+M^-kn9yes=6z&^$GZ}w45cz#oI#)n(@C^f3@Jq=WAwshP&lioga-F zrrea^*}h6#q)UX`0cb`Wxhg)Rg=_gM^qFSh%w#eG_8QuHH_e@MHj6c`wA)*hCl6-< z$46g>H5lAiU|{Zxj~4xrm6_x0>?1$FW5>0gvm{!!*-Hq@fLklp>$gyfqlCHp66^I( zM(&KBtz+I@w5YO5)8!PbrEAJcEgeCVfd$>3kd8?4tBfqgFWE@a3=HM3{Gq%UpP|<0 z@C(^DIDPR%LgSh6x_1HmSxv=kP0&dRNab_mh7k=zb4vU1fuvCp*dW+-3}Z&A`e-#Z z!YUjCJ`k55q&ymx)DwFtK)z3a>jw`ps*Lt-Sf6)88`QQd!MEf_an&U2$iP{gEU`H# zFElB}r?=UPJGD^1-q~|hS-{#>4@c2<{tyskYn-IDES~{9_Q^OWgJg^$5OR)>-i}q1 zMha@Eetwh61r-9WUT-#Trp=7&Ogwg-%%bu3{%=@Yka@{^bppsfNvKn*}~%jTPjU&(>M{e<muMQLa_07lwSe&F>>^JOXf zM{7O%VDn@vlggnF`e8~Rc=_3%a(Q!X|4eD+YKa~~b|6Y9k~6*1vh%pUdCoHHy>6~p zDq7h?R1@{6m(hKzQ6m?>pDD%P>04AHU=%+4hdA0*JJcGuCdAOt@x4p&q5uP0QoKoY zNM>6{@K~XXXCiDCvZbQdJZ|u^Rt}CrrTHabfbbYK&4iC|h z-QdIZO;k66BpWg1V>N)1tZg&u#sFhWmDr*IXWSBGZjeZKTu}kp(nZHTi+he14>_0* zj@`RTI|DVgaOw5>spe?Hg&CQ&l2m|z@HEyZoIJ9!+P)zLR&lqx5CXKD9QL3d1V*}2 z@aKo32s^e-JjMk{K~#Dk>&r)Ywd-i2SbZ(?Ar75dNaO!2(o)?ksh;)ROVifH*$mu= zLTtnvin=5hbb1t;!Pu-Bw+u@`Nw`eI?pQ?{J@Ga-mqr>4+^nxd?y07M{z+0Zx=f*6 zKFg+DH|waFC+AeWHB}6kC8gzj5GAoctnjqvd>`!iV_P7QFHj@V#pt$|gcJ7@tX`z) z=O>l}zQX5=S;vu^+iY7P`UC7kT0gAUstRI7$`#ao5x>(MkXc58q!aDEt{ya;l|@7^ z!oLol5_MQQoc9!7mk~W{Ffb3e{P218x!^db>+(Z&A@cA_cfIS1_AV4H6B}dc-xREh z$22Qka&_~JTA&}#eBoPDJ^VRyjoG33mtFOmz}9cXOH`9>@{F#S?T7LzruMgNB!xAxu$^_ z8$L;Jy-&61jYOMS1iG-I@;MvHV_S0JkUl~<#5H_$Jktd-LdP8ci^*yW1YIt@zE8!k zE>rJw`=z#p{;BR|=an7B3*6nPab`W`_b5{25yTIzss7tlh|pD~DHR7D(2+6@59uB8 z-l9S4*Zx8TJbUnrgJf>mr$x}SvfN(DPGV=0eth(gWuXg8<-0I9UR#7LT?T^6oyDQ) z*|>B;a`bK22dm3HN)xBumKVd5Aw}`=##I$n(trU@@FFksf#=U@s$)y)(jPp!rDAtr z)n+l_saW}o7sNXfO>-(&iJ6vP-b*fL0mpolPQGsJXpTiDjRh~v7l5O~?k;r15g=ktPel`#{tguP< zEX_d^y%X8!gf#oDp6f0V0IvAk%D zaV4-{g>(8lir*effi$g+u+@~+ze4n-6DNRkr8n}xVd4+ehZk=HfX(d*iGdSviHEHi zh5f2&*iUF2Yr!v8S*@{iRpegSQj;g8dyGtzvG?$RlUM)*T4?uQ127pWCgy*Zy@UtBX{v!c`Iv+Y3KXuTb}Jd{6s9zyr!Q z5&KUpO~~|5UY)~02o+(7;-wTVxg>Zaz@^rl;i}SkVhKRm47L=Mbph;*iOhe@u6W4H zgUU4IxE?}m58XTn_LaBlZ?c~#9Z3T z`mYT3fO67av3ErIe~JZud*KYU!q3?F!@ug<%kZcSAZyEn5lbT9mT*IxVjBqIRd93Tk- z0rc>{68HaP2O!MfyZP^4{oc*r%*F4s`P)_X-DrF_8vmzl<_V4#wF}yooO1?lDc@&d MYGZ=AeEac#0oFJk4*&oF literal 46438 zcmeFZbyQVd_clzagb0X$(jg$--J&2}N~ge~yIV>G1Oy}wEh!Dsjf8-7cf+Cc5Ypc| z_x;rS`@V0C_s@5X_c>#1&pvDKRddaG&1jAo-a8N$*VPyXhM*5`MovhR! z%Xnn@$;D(y;#1*Qe#zkEulx$Zr6Gbjk6|W@dSM8E40+YObe^IJ;j%aivzpvvC5xcpQ@492H z6A1I?DKdID_%*oWh4DuZuNV;l`OtByTS}SmPeGa_NKc5cu6E-Rq50^c8q-<4C;~*V zhwN13G=rh*r*Mv!zT!_iKH}!dIa0S>*r!bMELIMxbB={68^Yd)K$1+ zKwRUq4u|9GDgU|)4|}JAlOt*i{kJKNydHKO&8HoH9g=c?RV!gmKL>rz`W5@g zk=Z=tQsU-9=88kCmSD`e^TxsM`=dAqI$I=Cd!pJ$56**`8eYcF9@KC$6;aLjg)fCL zjoC>%WM+IddLkCqKLjOqB`yhM{H=SJND)_R5qJT66!}mu{YvsIxD1EHGL)t5k>N&J z?9{y?jKT-ZyZjR$stl#$-0izWwq_kr3p-CPVzFI5?n4hW)S)tUY7uIAqeSDHY3AB# z$B;F}c*TTJE>5^6>7QS?ohyT{T{Slx!c(t zL?7sGIv}0ZwM(}xAEaQDG;5C`@%w3Jd|5B#*PX^)iVb{pAnIfIu+Ja+bJw$4WVWRT zC=xF|p*c`u$OkQ$wfV53s{NE_$9dxaxQw(2dEF3y3bUZqwv1vdkk0;b%DseE0(;cC z;D$WZMx^;AvOk|G(J{VJJ$yyMA;FC2)$z=TM)VEk&aapFUxzEUzp zJDOmrM(j#HqM?t*dPCy)z~hrMb!oKHi;o3ag5U8hgCk!}zj%NxxjKYleqZn&i#Sn+ z+%RY5!9tx?hxs~@8DfAevcFSYJ`3| zwowLH21C@_SK87uuLTwBWw$APC<~(%x(C)M9XYjLvOZ6Uaf^wHE{#c#7E|M9h<~Ca z`~3AAsG^qCw3J1*ZmzSks9M$ciMMu^q&(q*QQA_)IceV(vYJ)Y)VS2(%Hitxh5hfC z)Mg7^_4mHbNW0j$2)UGahSk6H$-jJbiofkPwm4+Y&6GsJ0*#l}kuA(k%@oP-88S5I zw>;zyB6i|tou!jb19se*@z8^>y4WOw+z zdBjNA*za-e%qq2=fC(p?nCdv!jA~^s|1?R*9j6#J7xAxWbi~!}ntNr1 z?F&2DlkideErOlB;f8PLGU+)K-_wg*Ubg7pH6z;^n&H~8j~izFd=^-;EZE-k5$hun z&Z5{gpXAuk0Q)cdkIU-qgze-s##x3m`NM))YEpW?D)t#8xh6<4sgPSqau7nX1c+!hNz zyqu7W#gf^#B3ttZ3m40zc3OqEovBTndDT$C!ggI%8qZ*TYyGdwH0=IglaMfloPHOb zOqN;RS=Ycl0->>ian{k2w*4O>mIn~F>P5pv-T?MC;~z{NUZ9gIn@eUyl35k^N zvhRA?<=K@$!y-i&rORkMIw7YO}G#tMu z0dfU$3XyqXxbV5x?fUvs++Nk_&c)Lmj!cf(U&3d+aG00fk?pzBMfRojnZ-HJ>H3xW zrRXu&IsT>1x!14qbrT0y2mMQ-i{5LTi7W{G(+T{$eqZ={xkkZ{_FlY0eu0S z0hR&vfqH>vXzA!wK|#Sq=yjc3D;VzMjr+>yLt5YR+Q-`8xBqS@51tL~Z6oQ*G?wft zA@*Wx<9zv8g#v@xBf60KKw3*$Ayn|O!sp1al25+GT#OJ%U!A7ZZuV4a`N7KdYJBH> zL`|qh2Os?qzUt!w`~~{^&r9U3Dfk@ciX0MR9>#RYVE1IN1+DG%h^(S^l|EaHUP)5@ z>gW`38f1abD$kvm$aS>&{POv2Hfny*SjSjuzQLd9Ygu0vNHiVYqPXvPggx01DzrfFeey`7~$UjE=UZ5oX1TZo=dj!mqfPe@5l z^3u#?>4{HCvljN4S>w%bs(dpe+UfK=iDCW+`?RY7bvmV8_ozR`B?~4=r&3tRh3Eyr z1w}5Mya>C3Rg#HzuXDpzc%B#&I^!SH`=XNUSsuAteI8sC11o(qoFvdx-Ug4ziyCcI8^{BDYd#ku3?yq1n-!t)Cr5c z8!dMW>c@-5I|~;}pp~$f`EpyiRby3QRePq(rr0~o6D$VjIl>j!Qo(+mX{R4|h)Vrh zBx;wprCK7Yl0WmtSzlIaHC~*>FR{49smJB$gzCU+MCaqn_U71j4GL;Vtl6e-T|!Kx zz23=>Z&{viZ>N3~N*K@J2}>H#V`wPZeKa+%yrbM;isPizqhF>`ejeqMe`ULmGrQ>h z!6)M^WBuo8$kULpoiFuUF7v0*(>-h%Y?}~C96mlz9&*7IcN6JY3$;#_`K;bZl_(be z39rT__d$%U(303^^b~wbmd#$4bNM@YSxPl&7(!0B)F1)0&OhO8%i+7> zrwMbc<5jiQq?hBTlaj@@#V!f+Y<#*$wIfr!xqDOZ77OW1@97G8rMlgWM0_H#;ajy^ zXpVC%ys17sg9{0`>EFcP&2BU}9pLQ~EKW8WT(%xctRGaj99XzniCwZU8TDpONxM;C zk+-;0`yJ;P|5Te99WkRDEJzm;v)g{S-FV#fM@&%vje++*e zmSCTT$>iBtruE)GKIte=mC0>JklmY5k+dHpJ#@Ftj?_rSxx&m896h5tOfTDhIIS^< z^gVd-WLWQT2S$kGRfBAJ?{td+>D4gOCpgmP=5V0P67!_n{#!z7VG`@e%cS_^`?m@z zsWN_tUgkJo3s?=!@7Uy}0uG*49BqNvBFt1%2BM&V#0c)ukR;t3NJt?TNNE3fM-hA@K3~9vsPp$XYRpHZ2jJIZaJgro{PS&8-VD@#-XkA^ zXGjuik}@*jTg}+f)YR6=+|HSyVORnbVA#LWaza8Pq(xlFGOEwP>&O!pZ#A7Y732kt z?QB?$Ozhs9vbx*YBica{b{7Pfo0MwYjsiy&xN#o0}V}8yBmcqZu2A zfPerSJ0}|_CkuFk#mU3g*~p#6)`{ltM*i8(D^n+9M+U~^7#x+5jAF0ovj3L>eL!maX!SqFD1scM zu%G+|6EWa_H-S!4^1lpO{J#VI$9nx&g8$XQf0*R|okk)afuG236tFc@WfhSSvx2}p za}Sgz&r{C%1~mtf56gPp%%D86!Z!eIboxq!#(e7r@M4M#Q5C zey?J`I-*4K??b1w=9E;B`roZd$zTmhb$e8?f!#Icp3guIFxNj9-`%J~60(b}^a_K@ zn)=BufHVdGs|?Eg_x!b~Ka}O?Ok^&WxR0V@l&9KKrsd@z-%N0v#fnJy~D6M~FE9m2Pi-9@b&VC_&l7iHhyM!%& z3ZBM*rfG+lkde`|amA;y#h0I;S%!kg@!Ekuz;Lu4i%*%zBsWri@dLeF*HH1i;0LYz zBuU%`a-;=K%Vz#WMZtYT^`T70zl{NX>@j%EQgz?d9|`Tp^ABY`F}xlUly{()3p+_m zWqZ&{03Z8!AQGA^X!`4`zz1lQUtR*}M+sKL{f-d6wk7s&6l8Q08Bj=Ta~vRk3wk+T z{5Y1e1X{UgXISt68T~70nj!Q)4!SsHFc8T-%sfhEIq}{lu_K6xi;JQ=AO1_BK{%)_)u5sXJE9$*YR^gr3l03Zu&3d=v zwihkg@5C43YN~0qp$J(FkF*{?ew%!uWZ-8fdUqhrdg;t1Zx^p`*I5vgbbVz4hh?!r zp=QaQ%ni%G-{nYT9|Id+zgWUTM(+Ujx+2UUAl?s*>#37^9!-B&Y4fDvth26%OvUX& zahE#(OPaJgj#sDm zq~1cjm`0vuQO-@+f_3q3x7)7ODIw`zyxw!c5@$2}N&L?VTLg@EqkCxo5aX&{po zIJ_18^UVxB>w>Ipbd&wk%T!+k0A9eldvH-AXg-vMVkK{xCH?Gc+Ptm)XXoJ2nOzGB|yl@6s6N_*dYv&KYpmBLrbOGcQ|5cV|;@1Bv`XV+{(-0m;{ zr%WSjsQ!$IRcV976;E)47%Z}7GH|g#QAGr=6}iq9%rfDR#myQysVCWjx9j}&Za4a7 zl^P)vQ${ouV>7+dr&bYUK?*6CZs|TYX^q>+T!myRT7HA95ZJU?C>Y4*&Bg-vhK+~r z6MQ51muFt>_;^wJO`g#G`3C7Ogm1(}2fe=}-j&R_4{m;Akt~`#Mo!rGg2>O2diFCw zs%$3U5>vd6OXc6)Z}|FY#Wy{#1X3IbT)NSv;O2NQ3Wn3bIr-4l*>Xa_Eb5y3ZZ$Arfmh7u zCQ!?;E)uf5PGmJ^9FZtxIwE8|*^pU1HUcJwEy@R#0 z^$#xIn!vQPeW!h%SIr73MRFxj*hh+)DiVuyR2mpsHqg%sVrmY7RhJ-%x0J{T22NHR zow?^_Mjg0gB;uuLK8X~_>s7(KEa}=as!y$Sb1q61yZbXb=eB?ii(YoCUC%O3I};Bu zK9>kdTE8u~9O)GnUD0=HFTZo#xDxgJ=4H`rlFpTigpR5twt52D)8pgudbGc#|HE$E zE8k=5jDCB)=FfI-(#@^Q*IusAe$&2Y(1Zg|M&b3K-lZeHUjx|lTW=fY4tpVI9zux~ zT@jq=%}4&vhF68*r+)^Gmn)giCc5@*tE+`84SwHTuCl@Evpzx{&c*C}E2vqWA7i#m zn^vD5vuN19;aSs zM|4mz*Khp#X!bzG{=%Zp0weA2;cbn_$w)&=Z79kV*|^Izwg$(}ZgswvUDQCdhFVYr z+9LFhpIWx}VAtVx8M2Dna4A{ia~t2)H*cnA)qJ}YKIK8SDX8Pr<(D`RVs}7Tr<~|( zbz;x7G>%1@qUjM{r7%cx^}sBwDZGrq^-5I;otqEXgcClk2s$?xxLa?7T_j7-CM?0T z2&e91jOXNv#g5j6(r1Y7Bu;}qo#WK(Obg&&Jrv=jLwjjd0W^Cx%lcsC_#)O}OEicu>_gDP+DWGp8&f*9hIOjywAc)6NvW z&IL774;Od1vwC*oYPpSQoLUVFB_FZbI973c~Q*KIkhheD2c;v?>11@~(U*;s=e zMiTe}mt9BOrZ~;B@RI7v=rU5T!O_#}c6_-RSN_|dKZo;k(Mf#NEQVlRuPiQ7v&%f* zule=4NaMg4@^vsu;R{U*p%a6Pms2U#3r6|NZL#Piu&JO))GI&-0l5E#7h?eJ6-?V8 z$98^+5;~+LJ_k~Z<)w=aV8>tapx^2ws%xsQti*Zjti86uBv#>Q;DR?JdrsZ#2r&mokyEclyA<9%kJo zV&8nR)LGMCm#jcwNz9<**%YTc-{QT251&ZXNcC7cyQv4K`MWEzPAKfRWXZo3hEI%? zA}o7)=9F?Fj^E0Lzb}O{?Hc?3U^?@t>6VpCG@UT$xi$V{k6LGh_$0V-+|sA78>4QyZ4Of{t(&^Wftlw&+q%g z5X73znww(1CCh*V2fKk~qM`*BNs=5WMM}ZbZG$S6^dMwzCGs@0QS$CaAt6nuL(0Si z!oAY!)DOJpcAEuv&Z62KC!Dn|L+FvTwe!o~Lvo*U0ozAmk+j-rq%M9T;ltTM!h9B_oY%Mk@;LH#BkaSX5 zdFAjJ#7=*8^E;?+=7|;(JR{3UIPe~U)>+(!>8eh>DNC_U5Idu@CZ?SR=0@WhMswt` z@l2zkdZb*}QENZDY&b;bRDrr_sn_D|n=I8NFL-NkP2%i>Nau}HyHYGqXvEnfqhLIG z-TGH1Yag6Yv=ZTD&iDAVwr-JHj&AhI&a6*(Ie!WnXX&d9y{plmBz2w7A3o|h@N*@7 zuFAXJZ0f8p#7vIcZUlG%IVlL|))8J6{*wr$7(|!&M7)+?pu5j)nx?kzxdBo*VU<-! z2%RK#yYq03xo8|X;1W7t>x=M`hN)Fn zxXB#;CP0+bm2$jRme`6pTIXJa3`rsLh#sYW7`RZ+d#s2_f-%H$xl4KxA_QuzwBHq( z%@BS(ztX}Y(X*dsj=c|q#S1~`H%7#}PW+lq>~f%Amuw?E zK!pE9-AU(;!610DMvAp8_AoD1wAoVY$<%(3lP>opq$NC$`OVOmp+k#J)Ntb0G4R;A zru0QiR=K;XM`t0hPstCG2pWo>ky+(^yMBqM%t(=wbr0uj{9GFov#p}f_WPPrKaDB8 z0wOz-)kxFdJWCh7@8Ld~A6)as90z0fep@#YEpUu8GkrcdKh;a~vlV(LjO}}O-^zA; z!6U>aDW}~UHg~6Q%gS>WoIw6zmLcGVg z&fT*9u;C??t)dx#t;~>#Xz(h@$!!Ml&kCO+CBLY!wbm4 zK@B>I()-_pM)(^)@j&>V2H2r6g*VmEHV8EwjIvZB_FY3wpcxjSb4C+IJxWUxa(f>( zSUI@*1)YlK2ezF}D!+^OWd#J%PUsG#91eXIVC-kG9xL?g2oQQjYc6AymGi|;@JKZw zA^xN2K`KL=R5BI?rP^T5J8=cbh(_a_0+Az2ok8&QZAW=C?7cgh1$pj|a`4(2$lh@XQeW znrv2o(y+e9w3~@!Jhy`K4P<&m)~<8c38tK^ z_`Ml&p>Vq&zFv)<{PYna&Zkwxyv)J2TQ0J=yJSYy-*mq?jMp$#vcD$Xdp${(>9a-9 zYl@bWlDnCZM2x$Z5vcm)$ZwJ zXhNj6Qf`k45M=ywBuRx@MA^}nRp>@TaZjq~N8)-A+;+1viOiW-zG7uETJ6oqNu?n3 z&GR_S)R}+q`o<$}qeGgNJBbKJ*7hql@rUXEXMh1>6CP#9#~xjA3EVyuP3+o~OFwP~ zIN26YzgqR)i{IPq8!xb!0wZNnn)mufmX)yQx7$)R4G&189sc^5mtrbPJ&nY>^2ekC z>Rf>ZEk5Jw@Droe*zqu{$sYXna`J13`8JfdImv`Pb3vY5#g zzalub*Gr29gA{aZJhrj@w*lEMSe0T%H<3az8Z9eRF%DXJ-Sv;C=l7boLdqnXr-0Ai z%^C+$-T2SBP^oLCq%xIpo-KpeyY&+kk;C9#S;cyw$tDn&uRQ0m$}u{ArTu-)?U%JT zzizD_8D$ACdN`)kWz2@EoF!RRbS<^dJ6K_mn3mv4MZ{Pt%HmRUnf4g`mnr3J#;u54Yyr13m zaRNUff`FIUA!%UX-E>dn5X*VgJ0D&;frC7`L9)I9GkL8SR1DVK8VhutV{P)V>y{9`;L?W}3P&lJ+_ApQ&EL<+b@1mrsM16a^OgLNn$unsyMlb2Q3Y zxNxc`H5Dbl(jOyypzqMAWtA5Z;W;#pPd?C)Xq5Y`pSqCQ06aN%@p0uR1P3s;|t zNjAQ%>uV)q;4h6r~L8W5XDlr#hT)!TgS^1tV%h`3Q+wOSI zk>}3Jcy&3Ba}FCHcnAv8bL7}vUZ3>izygUDT^EgxnNDB(W7Y?jF_^cjvY=Puf?xy7 zpH>TD3-0)hX#QM2&FLFyoXs2@fP~y$HApsji`g$L z47`1w8IM0?ws>Xh`HUbBfhy+ig}lg_7|Ru>_iHY zRYpwTH0C?ed1tIW5WJf6&Argc8*i+c{dnP`X>aQ^kgU;l-R}L{2c9$LX8k^NI*`bY z!g#;p<+5A@x9baeZk+7iWuZh2*LS5L(jR%P)01?(m6d6s=wx%E2aVGA;Nx^qJu zTS?n-7f`bj2fwM8-N|-u@xIF9Ve_8){OmN{lZN})z!3U+E`)t@$~S9@G-Zv^6H_O2 zw%Nd~VqK--l|(yU@(ru#@1hzrqK!PO-8io_PGhM#vjq=J-6snN=G^kR4$0*8de*Oo zuB?QXn+E$nkq5rdiV`ec?<3N&dDb9*@^wdfBWxF!_qDCw_TRybg>P>5zXF`{4m#hv z3LQLo_%p(?fZTQzr_SryZGZLs%f12Vh0h2GNk2q?msK1n4T|U(4mua*S@h=q39amJ zIiddLCzl)ZCjx^eI?LPldRvI=N|>x~E_}d@TYGXwk(sAd3gXT+6%F+uOkH%=G*27r zWElBM#(pJh5Kz(`W=$FGBs&^W=HJ!o7| zampG%dh2WiF&EU)-%MFo^z+)})MHOnl$4bGWXHaj2uZRa#taZcONtOzE7dBQ%vTt$ zFjIm}$#?z)2hv|aGL;O{GA;`4cLfw2vFoyD?1mqp+!irV*BOg`1HYT`?*ZHE_BnCt zoHWvr)^CMTBs22SN}4Y%xtuqLr07@gMFgo#mgx1j`lFJh-p4}-qVnHa5CU|R5*0UR zv&nYN7rEQo51(;%SeO00|E`Ok8m)jf!|EJ9C}vklsp0$h`0=TZU6V`IG(I?E{~(?W z#$|!uFHcLQ{1CW*2A?1~9_0`z+J4iy{%DKeT|vzD zTwR60`J7W>nA~$j{*nzM7lEaU*CkjS8NHiyW540R)>)yX4n|yYecbzZeOcw7io-7$VzUp^BPDV4N;H47G>>1T4jp&|^ddJWqsdsTwXH=hx0eK6RFsI6jcR#7cj9%@ z>m@1k(0)adSv?ZkWru^AeJ%EIKzBr07KW(Z?|TB?9k5lWptsO&r?R``o5YiJW%JyXvi)Owd3k1IV0f1yPGV6_8aO z<>9E3n1Dpqc+MK{tTk5!d_X7}%wxo7mah0QeVThFRxGSb0KJkBnMG@%Sck8s-eBe> z%GGW~6x3-zAj@u%xxC{sEYUcUW2dCS)`|e?O@%P60AMLZ{jaE~hN>|>lZ(uRCSl;y zB|d{B{ya6bl0d5KWowz!b)7aXkINlfZ8{l|OWrAMX|@T>I^&UB3S%l^PtbLgX*y0>vFCcp;iX`2V zZTihvSWCW5psxANn#H#9Na}or@9r9&tdb=K^?*V1tlfpFl(EoGQi-ARCDk*QQZNf_ zfe6EG?F1dORF^qBNhf>zzcMIW9S3z$fyH9uxvxg>&Ao`=V-h-SMV&vlWMs*N%{Xt3 zbTZei#l%Tbk)mnGwE_X|_w{j`6^+=jh@4FsD|YxDbrRWDaTh1$;>nTzWhROr@RF;2 z??PhH36o#^9c#LO^3aH2N!F-IG>8`U3R)42KRkDgfimgtr$s)seo4uJc*K)1{O;2E?xbDr2%Rgg4M@)qo4C6b8F3_?~Z;4x%Z)j zn||-YHfj(UkQX!Q1MqMit&+K35c6bs@HL@UhszV|+U1XN^x|(2iXwo>Bl191^+sQd zXl=(4+tqKrJ#V>#YMG^uH+gR{?E-bm*g{+IyYo4}@&v-vdylXuf#YtPTf5jm*;E#WTkcr4;eznMz8+e2{NT<@5QD`QA+M7UPvWB zF`%&%Q*94P=}r%$mDnD&PE&pyXu#70Jck^u0()<&+Ma>x=5j7pT=()VJW=$yx; z=~aH-Q;-Q(N$w92|JM!c&5xl=$k%4Cz`v%8$j!>Ee17@?^vl1I;?&1dGq)m}MQ)^^ zy+8C#@-GEMLP3~#-C-LR*{AgcgM}3Ll~88pR#T7+_XNOIE^hxDTFRkIv|kYz_KOYJWdW>>(X<{^kv~=hOLPzTI|gwY)qCiby#6G%wLc${3r`Uk z2NS5fZ~%=-kz_)G4i#rJR+E|x437}1`Vq81 z40(l)NQ(Q=Y|W|yKkA}L2FhHQ@U6IJL6G)&9(YTeK4Y=GqaTZ{aVc!3P=jr>$?II3 zjdvX(He5uO$?_7=x9>r%Jd?V{A0Iwj2`DN5MGVW1Rpbn3sn(=b!2sqt#_k`^THmef zGXAb4C{@Ufmpo)@qTR5X(U$6wFP5+9Jew+64xa$_U4 zlb{GZKkTN7O5_?89=-mv8Ul z4xy;;>D=yFNA(;3^868+W%>uvGA6arjWn+Tf@-C7QOH5ldC5(H+Dl+e3ILfaw_th4 zxZgj2P*)h)mT@dBZLZ(>*?CgL=^7Luo`&&|OmIxs=XAUTGfC3zH$z_HGqV(q!FQRv zV95SJl9+R~Z^2H7gm_bR&Ae-}oa|!G@VNy*Oz~MN%F$8aBnRbuw?m->HiSJ`(#)evefDitD4kzs@pb&H0)LPmZ6#410uxq2hKvs zAq&S#QE0DHan5;XqF6mlS^(jX|Cw>0LGC}CB`u@^2Yx*n*!4_PM^Wuc*lC*s@O9=o zXqDev_i7gR8T(hLYzp`CSMe*Zmb6G))60VUztWpALO2hLJP9<;abfdo-_~EypoPf+>YAh@ zQmpk(n5`gFN&Oo(af)${W{FrD}Quix$(XQpD4^Ns@h3z3dVwK*?8ule}*xu^| zJg&3o$8SbkkIi5r{o0YupuhY!0(0qTo&)w1-kR=rYk~0iaNx-r0@9sFWFTE+ZhacG zFIWIN$D#oY7NNIY@HyAxI%tF^aSiad7XiED(Nf0*%JKxUZ|ZI%8v*i9TY<3lUqujN z0FEUvC)ee?)$3>*{^8nK+=N=K;Tobc_RRn~&Ok?IA{onLg4?g%FoK?h8Vo0D3c zG969dYmX;u7@VVBkFY$>z}aH~lQX^J9fmY{-TNjZ53S}LTcZN30{xA?u>=Rq?RpJATB5D@)xaK-2oQ6O`c+ zy#|Z`{4TfX&x?|MFSit~&h{=Q6#8q3?-jj6 z0TiHNxxwjF=Yf%0x=dk;#8f5bSsSE4e^@4#uI_RR#s`uV393sJil4US=A)5BMwMA4 zs_0-GO&a(?Dg3U7`_F-rZl%-?69RX}9SL}HDiOHkU}ku{P#QMtkK1@Rz{vZ!N9B#3 zSCbunJ;TzOgnb9C3A~6`LIr1kN;>f3!a^q+K0s|7NXm!1Bcu@24sa4&-mM~V>2ivN zm-Uc(P;tFgdclSjVWl0Uu>{+zmqG6689UoHfA{}gqK;6mRi1~u`c*F!MhR!sd&uynY|{$H7{NP@EjSl zPh~P2F&A2h#6_%Fs?1yqh0rn!;uhKWG}esY#$4MeZD(v})!^F*e>Y9_AHA474I@^6 zR!|~?$2;y|CC?nBU~FF1U$)E``FXCg-|B-l-~SZOpwGw0JYv^}fjWBVDr!DX)Yb)N z8W!?F3OU&z8K~p0kb4neX|l6ExLBd-ucu5Rg(shJs*Za1hm=`}#^ve3-=9T3L63co`O>{n1V+#IOk~;9#xf`%2rv{WkC6qtZKrKd$$4RZbG z2nICO>Ee5Yx~@a#efStphLwckWJSr&P}A{mrjk9Trl?KSXeE);w_iIDka9rmv6Yk1 z2bhRnA_ynCm|L!oRsN_7)rTy49W=pi&KKR*#}A}Vb&P@u<_sEK_hes347B`N35Nm2 zcIoP6u8@Cy{>?1iXQKGy_cx4rzjso7aRx=sQ^bng3%$Z?D|4|rBQz&QREGwI;h(us zCvM8CG#n72Ngt7c_i~cu;nGh?f}X2IKcrCb07OM>1=2Y!b(MW5LZkqHv;g(!ACY5A zCCu&|v~W?qVQtc}%+Kk2iN&&pGk=Egx2M?Yda?9Mu7kCv33?Or8<13&3C)x>&1Pn8>!ZQv^j4-2krZH)anfRk1OVt3bxezXtnE(ZPje6RL40%YB{J00S+AoU)GxEd)m&?gMs6WGE!|}T_1scpdmNf_3<4YM#+RLBD@|>p9A|cwt&XosB_?IYK+b^-T)0pg##twy%V4S+9^MzSm`=9rED6z>A9vKGq89udb8C8Il zech!ntunY0!MCs}wO#LapwF8R!{s%)Pu%p3)8x}|ukj`>FI7bRv=TnA=L;+6EGi;Q z5|K?>d&!O92v0r=k2O3#S)xn&^H^{;ZB-H~W$iO18yq2sS3U2lgW%;?-%V>6?|@=L zCUl+p$LznAO?zyl_P5-gmX6sk_b5-1x-a>|K*SW;cDql*;Z1MzoGEY-0f6J%LK%ap)r(dRsl2gi~XOoJHXc1Ty% zbjbWmP`JE2cg#r-efp;J8uxYvUIuW@QXAKba#$ocQj8p_BjKi2uDl-PzDyG

AnN zyGfPE#ae6@UL&BpbI{9Rwq&ii_6__6_-uXI#c?m&JbDO)K+{@`TwkQn{-(;irY5kh zmHIBUq@6ZiGgAUS@C%bfZxX~rWjkfpK>Mf*!%h78rVLRhIrxG57GMifh#C579Hbza z&^K63YeZA)dGrj%LQ8%uieb_G#my6Axc7kAs%Tk}%32s2KMSF? zej6x>BQll(_Hk*MMs?ey;LipCF?vBU-~0SmEZkwtKWn%L#FJc@Oh;KB#663SKt8AX zg+xM8iev;y4U~|2oa%Tr`q&vQc(ZeqP5&@42l~N1a|2A?kzf)m*wMkpB}Y`=vKAS$reG~eh~6D|49;ONp^>R^T?B#N9;>`ZGwr# zfr&9#KiB1<&jW-!pTH6%kEH1|eg3ZR$jLE^)eZb)d{Uku-SgVEi>LK;H@dtO7{IK@ z7A>9CJ1N(h$Q?bmPDEJW zQ{rWcL8bD|-ioA0aG)xSn9>yf*rt7Tw#BGsOo;P^AuiFK#85$T(jZ^3klZNMxrp~< zU0ujxoti^g$l^A-@AX@QsvH=V`H6(Q33X=mWC4R{^9Z~Iq?9$i@-)Iqol5XLO1Np} z?;evfo#e^nJ(`SClAf&C{x(cuS4}faJ6btpL7QUlXm6}A97DrvG&8dZ^e;8>Xk)yv zG@)Ph6uvygn<^gPCY3W@l}!LY@sg&cdm3Up7va(F1KoNUU}z z`)*NpMOk!yqP%@0mPY*8f&RJPJPj=h4`vE!v8wPqJx_gsS0jvI(<750N0?^b%Y|+D zw`=`|dA7l1{LI^PvKIEhG!E@+39R(Zlr+8i8oC2$Js-no^|cr+DEQ5nCy=hRNd(&( zyfU)@tlBud#9Re^J?F=cXny^>_k^3H<2PCdNV3wCk+qv_jT;)A@3TCNIHSMz#O_Zs zO3xg0Y!nuk#-6pip;gtW&*SX4)iU<>7;~CtU(6Mt_5sfhA^i3A3=0noO#{IK+cdKFMC+^~!^RVf(L9F4c zNoo_-m=?*f;8j-WK(B!>CdKXaw?08H)Tm&%#dLVfKneRhGr#U~e$i$h`&#$kavQsh zrY>i>I&3Un#syxR-$Hbs3gtHrG@DFfO_@mIAtB+OA^tCbti}QU)z5-VPv3h?CrdPy zF{bPFzaSiF9+Fz#Wqj%4>M?1xgZAxH# zo5^X@Amfd_0`vT>(A3lv9W#pW<%gohfTkX}AWk4G=gJf@?I^Nc0`FrNRNw?^YM&Vy znAz33oY8()^e*lWy|`*D60uFyyU86WVJ7Mw#GHQ7NPxr9@)_9Kby)6Ub2?@h5r z#ZxYm*#y5&+ja!2*xX$#ttjjOr!65G$fK~0opaF(y8S(wmtXue_*Q+YijDWeGgM+wj0snXXdFs7*VN{H>0i-9lwqG zPqyn_cGUB((o0(wxB0B37q@p+r&uG?9$=P=RpqQO6zyf$57rZV_EaisYdjo`v}F9g z-~{yj4d6WN37!lODSlOJWDk^(e)U|1i;=dB_uhbm4dnyrwby}n4p)a3IwFZhTK^Aw zZ{b$;6TOQnAdQp)(jeUun=V0V5Rfjnba%I;BHgVZjkJVx2+|Fk?(S|lv%j8u&+q;V z_c_mlk8btznVB_f*37)`yVh1d&6dZ=b_5C6U)CJK0rN*EBfXVXm?-92M4MpFup5%Y@a>+Gtm{mX>;-&$q-bwXM)xtOI#)Bw2D; z&%Qyi<>H3HCAAT)m6ejouJL#Y%`?4_dk?F^vhr-SVV)m5lM&bBz3m(Ev)jgO4eNJd z)z}&|(Cptq&Sy6+FRqE$=XR`=+$}UIq;#B<-1RLa*ll*HF_m?1_R^6LQeCF@JjjBe zBGWb5=p`#1HDT5FFJ1(*(p#Of@NIqz$f^iBYP(xKVeeCIqn|2x(W$UcZ%`JWpkJZ3 z0oWcxG+M!r1eO#zxFTUrwo3RB0`yDYE?tm*wB8oGtFdH)ycTDRoN@Xqd1d-H+O>yi zdT%(G5|Ly!H7rEC8|#9?r&|V&KF9xIGXqHU(Q3!De*J=9iw%t00LqywB<$ui=fO-C z+-g@PR!rQAX|(_SA?3qFsX;TtmtZGg z-;Hx#RzC7}?9`X|UNe(eFajWxgW$xUwRY>sB2n^K-162G6Z>&Mfcu5hfm7~qYl_5k z_XfYvaQcG5=+jxG?`=C4*$ny1?BDM6I?fN)k9`$&yeT%OEla}{3mUZV-{;~GM^raL zZSwiiKe+Dp50#&9a2K=OFTJagCoxi|o**8XK!4pN@+Fa5fl-Yf%il8HJK3o~fv1Dj z5jFYOU#nKZU&(FV>IQ40QcsOv=PX?hHBfWQneCF2BqQXrajKPJGbH|+R$Mz~9C|A*X*}z5Oa$8{sg(_?oup!{kX8)#nS^T`q1GT z?*>bARqkYi9vbfcEs+@RKQb5^+i3)(GCoBTP-8Y@&$hW`wChI&@SGr$q-fl z%P$)L$_NyXyfT&A`uhCuehTVJ(4a%|M#v!P(S($@Gi#xOb2`vcem*VlBHwWs9(ZaOu|~Vnm+I9)I2JP$|l}&Z)V3M zMrqyA35nlfEMK7h?1A%R_GiC^lNCk9tI_;l6WYC4mCR`mvHYnX&*L*5Vr4=vCLvQK z#!saCzP8`z@3hTJ<``N>zdU)Anb1fj>CPap9cj+lY!s>H;3V-_u}|svgMjX;X6rlx;9;(XV9> z*A8*NGafG23|T8bG2iBXwzre3|LW#Z0WUS(Lm6f`&zqC2kVXRwM_+xNv(TH00>Z&B z>q!p)LESFC{F;7p)Q(G4&!lZZM4>*DKvZ7N?xK99h8<45teqjI{t`k=JAqp54gEZ= z)!6lYOI20MimmB#nZa65g3}wlbjIQ4x9z6>nyz!#I>IJ zD1(t_3hrh|+84|eyk0H^=|)etd$Y)W>s3siO>~u$y>xc5QGBL&;hmh_ZLs(I4}p%P zkF4AU#Z4P|PWDD-U$2+Q^mg5c=JDr2%G#WnSW+71aU^ZzbhpriJ z)^VWjvjLZ?KN+#t3I%=WmIjB}LdGJdf5cftg?rCP(RQ-j6ke1od)-Yv?O?Et=kiLR zKe$u(`qPu+(6c)1a@{$=Jm)Ydndw?<{TY`ec{aq_>97@r>!a%OaLzpZ(8fS(wacr& zefPJG;+-3Q^qu?VB5J$2fvEOFdztWhxswDmwrccz3r7Blp=ktdL-~@!Bb3oPGjeY# zbZw?EdS?iKug||br+BATWrqEiSrp-iky}9glYKQXub`_rGWG5cmsFgaNC>4MBD38vthS5lSrL>nD{7yVE9dtFvkl181Y z>M`|6Do3l4Ouc`AtO7n{doSwvLbPO6o$zO!g3)hK#W46w{Tvm>GvgA!wwg!zp!gM1+R6c@Bd? zzvnSD##y#Ho_xr{GF)=2m6WaLg9b`rW$)x$^DFMrYF1XO7|2RRtoop(wpPrBg*Z%; z>vO)hlb&=fe05C#gJ;ljhFm;a@E>^(&E|K4{@#{R(BD~$;ISR*nl!T_s?G@tUoY4+ z=Gq%fS?%b-ODKxDTMZ4*l-6gm;ky_}36JmBxgZLk^|T@8IoS$!w5mE7q_0`(buXx( z6+DPZSCXxK)t38SM7FEsEy1`{;{nuNLY%r>y#$f5VI!oC?y3PCf7=>vn+PzEy?DC* zW==wk@-{p-6K9pQL(GP=9M&$5bhKe+M&px8Gb$&az1-WZft@W~h?tuN8gU_8gKXbHy8%Hyo}+10c9ODsa?|IgzZA z%=H$#Ud{R`LYc$f*K2MgdyJ?mV8GTadlG$DwLa%YIAB?~6io7UFaAW|CL+rFk!07B zVoWyp-O6>U7&@dm+OJvg<`SfPvl=NtpoA3+>y6k->+FJUvNBpOiFLXs8xW8DrIz3!C z8Wb-|r(6DTr$~uiPGA4F*+lW1LzLN}XP}%hK5}H#Ip?a>VX1*&ru#*|cueNL)_IOW zeF57lF#quE+d*kuo{fsM(X)~(LkEwiQ&0Uu4gV@SLQge%v>YvX^O({qi>~rL-fF%g zNyR759ggFNh@?9VI`lsoN&9B^G@?YXe|Utn&cRWwM;PCGeyN&v9cGqMxgPHBdcBob z?;7Z^b`G7Hh3FmFK`ar>UqaOL37Un`5A8cF9R&Fd!!^)<*JP4kPt2 zm_(%b)bE6sweiQLzz=J=In1+nC+3&dv{v3kQ!+QVvgNT+(chxWWVk%bilTCSJjL2x zFGwA3-B@2nS%S2uhTSj1&eeHk&?75*(wJo;cie*Sa_{64Zcw#C7Dy(U9xA*TK^l7yYu; z9`@&3!LKDQyyxWF-)MQ&stlqZlv*dW(cK!oX0UQHz@CDsEoJ=}w$Hjr^?2!xGsz^)AI8d!qveZ>B>RY|)xH9Fb-F(Gv+ucn)@skrTvfFBc zKm`XVUH=}S+C=N`u^&Bpc(9`BYX5(#A?~H4HK@0fbW!N2#5~KYLf?DyI;bN4=Nr!{ z+3Jd5nxh)_Hwi9#R7{ezc&xH2S$c0$@u=U(Ozr#n#PYk&Uo;&w9yD$=9;7=C+KOa? zqAiXl>wQ-u8^WeOrY0GVVkzitQ5#5bjS-;Jd{?VyY+?brt~a^Sa58W*VW?)e3oCa3 zG>m7x$iuB8qiXKgn)LK|d__L-^Szn8O2E|^Ojlc{Qr#45RZ=fEU#*kyFNeP5{80~N zDn>voAkgG1Hekzd2CUd#YUw~?K60p}RFd{izmJ>G5O6maR?Kx@GG z1cM+BNX>*yqRVNWr?ciX!3TU&lEQ|3%TAdo3@fiSkTGO8uzYE-YG|GR+JRtp`+}@zL@hf&ESr+c+r- zZ|;x2r=6EaD{BBCJIex0AO@q}@2Z;cg?+27rY$+C*(0R%$(_U{JF|(xGl_P z=$n7T*@Vugod7CVwB6Y{)%)um_A7v=(FdSlPoM|_aqJoxQaqgb84Xy`eszHZqnPw3 zW2;?6(5kjdj`+QnptIVFLQ&wFhH)+yM3< z5$FxvZkB(3W6Q^H160_?olE&0m@>Kk6rS=zM4uTlK^N{+89ksVR1&?8$#|O z{o7@)Fm~Q)RN?wQI9#(i)otz17p2p*k8A1tj=4>Az@>=G>%DKzj{yT2Quto7JAVNxI>dsY2T`g;WdQ!a?-)9pH)V`m^ zIHU4Qx=_h;+otM8)Z zg34O`saQ~+lID{4w5Bh-bYP2Dru*e?)!(Q{ z4_=t{e5sKS)N5!6?UG|?7-|UXsbPt}Y@5-N1+ll9VKoT`3j9 zXrp*O^S!vIn#95FgLu{VE|gZEzd5q7r0c9!i)Md`QZix-I#@X51}kTZqESA#%pGWq z8+eoXp8D{*_4&U@*w~r4?NNT6eu&)cEq-_kfHcx5w1qz>4~#BrjWvoi~0B~;qp;9m~uxiKnQPMi@_vO zPQ7mLZw`Lb(|Z^UW(dC|c6D>qEqu?I-skgoEul=@u$vsgWIKckpD!29%?~P_f=1-w z_uUe;mT{($o-VvmKDf;P?ud?0`*0>JOc(+=o7LiNLvAnTnC(GoP!4JNdRFnMx?M+= zdq}<|sshdwTWuR>wIP3ox^jQp1@jc}`#TCDJA-^JkU0DRdVLeFk7!hZ-^JIhd0bw{ ziJ473w*B==cMypb@e^uz@}?iTE@oa;NS@?{Umc4ThfyCUNqZd>wgmuY9PgBQmj+ME zD$wf}X|Dod`&df3Z z?gQm%-#cV#JB1bYiC+n*g7rgggQ1%na53~MKqbeuRH>|dZjYqIZE=#O9}nuBB4=wn zr@H2kq*?%>xHOU0L$j)8r8(XRjpUSW$tNTcMh6>oK8F!8Da$!iaq)?X38#(3Q7fsg zu#Ey}D|6ds%sw>oB9Hisxop0hdxXGtKKGdkorK3(i=668a-}FZHxb$Sy{(vGu4*_gdphwK0xkuUF1b^_{8;b(;f=WpW4TiDeA{0Z<0E6? z)L9~h(3so~sHDI+Gk!F(3kReXZ_VdyTm#p9EL=#TF@0 z(sDH1=7$mQug8D4Imf+;qm)x^?;9D@>EJ#~=mmMk==KnL*susM&$-~oW@1(Has#x! zP;1=~7x!%_UOC0??wy9NeNfX|wU?aMf1{|(WutP{O7(@Qu|_6=7CDO)#pnL234L3< zGIM*Lze8uAJfF>tp&#(!QY(j(Z8`72CCu%UVOYvsL?c1QX0 z+>AW=Yz>oI;p$qe_md0MaYV9KggSo6}pJv}qYYvl>{{N-)U2*p>9 zNMU}V96@}Z1o6H;4drG~ZNyP1(*9+UVj*b?3%UE8fj?4M_e!8~J9>1ovVhCyW_Zx( z5OTBUf|!y0SvODGa0Yb-QnP7Vmtvbc^Vots)>W5O^tz_F!=c0H>=Rd(E)NR-@>IBG zzt7X6D|g(j@!nsd$9D4dddlBl2XU9ZbF4YVYgRpLdn;{AX5$H)n_C8u+?bo421F7&?%KcNjoM6IilLPo8B2{0b*gR6=N;63 zR#1~`zHOaULlI8RZGp+ll0EhPvEf40UX>;auPO!UPMYI@jH<0ISU~pda^@u+ixnH4_g6Ow# zBZpG9sppXMBg3C7Ft-oM!JvcyqUeK{!IPI(ExP?^y>?{n`n4H{f?;Q=zjWK6te?#_^YXyqEYy*G{CT;B6o0Q5K?COLv@c z0xZwt2Upw+RxA1I;)L3osjv82gWxsE7B(u9?WAXIjYt;)+`D6Tjojl!8|mzl=1zLc zXjrJsFk;_Y3i=T~*EcY+DzTxrGiVpCLN#^+=NN3UY}&AIX`6&dIIrEhLmXQ0*-Pbp zINA=lQtYXB<;lld<3cQ-e?-{BX>j5>(rha^=W4Uzg>6-6Yd9)4UYpQa9U=^}VOY~N z>;~nRt&~5dG8{mW3*Ff?WuC@$OAbyn{ZQ!P+#m$(Gu##VD}2GoX(q6Z?mRoTjSi9+ z*L95xu^7=wA+9Go@h99taR9<vQIveP@`a>w-fZ5K?BISJ)0XN^naK=#@XScl}nHh*sM5tyWnNG78cPPQKlT5 zS!v{#ddpnefTzr^Dm5OSVeZqg2QiOjv>HkZ#8Q|#=Ufv##~~jgfPBE7VPf}7*2h>E zc$3XknUS=MPD1Jn1ZMN)j!ahXo)Vk5v`>%m&#Pdy_6^Ng8iZ+vd{AkJ;QvNhv(CdoJ=wea+!$<#D=6l`R!Hp~jiPj`x$I z)BB@W^>3=TU)R)mX_dnrlx@wcE|N7J?Z#$MTKU|N#y3OlcB~>I`I(Yv!u&WVf9ep9 z_I}xZdp0XBZcCrh%MxkYDeM!yfsxPn+@v>hTnH6@L1a;>60;BL& z+=em}oscr;XBKAoe%g6-qRvr0`}FWUn;hZy01>+t3d{v0A-KFx*s)-U%Kz@HFW{Nx zd#X#+$JiqDq?YEFR!UzPZzr|ktZVV313o+VFn;#o4M3RMxqsouN0r6&*w276*FIEj zw105mpd%wuvicjaqk^S1Jn(pogRn6$`%zzw$}*V@CsKVMrr?X{X9y+{;d(^tj}|YG z1ZN=J<{#v0p}x{}WkIYfPxzj_lgK;W?@L3G+~9Q_uShXOoV=)|HzD94#BL^;_2j}J z>cZ}#(=>U$NKCd7@wvZtA{%kG-OvYt^yy*^KJ#MTrv;9E#woPR#t$}1E}w|m=*a|} z4lDA>V^Lju=Oc)iljxCn=NJ%A@OS;Lyxc^fc)~&?A8oS87Nc6n)X-8<38(mMIa=@r zh!=zKsHPQ~7zZ;916CNIKN^dY?=+_!J>F5c1y%)YKR%3zoeJ~@ZP$5%qpC38NtB(B ztmI66Raf_XqbJ6CI^(P23xBG0hd=0c-#*>2Wz3wPAt{+(dbi4;G;F_MH+Fde_KM?0 z_i$4rpUGSWXkyR;cLuJVffdS_9u`$~HlB9Z?MqdHfjz(%93M|PLf;Wqz2T4|*vdl8 zxVrR0F5#kW$a3AW?!ygIrJk+d^)uxZx)BD+X#&KP91Zu$kFcH3Z4r+y_Y_kh{WQaJ zef%9<{XQ;BUSo27p-v`<&7K0-9NZ&`&mdbY$4>pIsYdS+!lh<)55rOAQ@XGTM1_!t z@i~VNyTTE8cih6~iZz$SG3~SF?Y2mWI>glo(<4q%n459Puxn=J#`fJ^#i07}L#8MC2iTlisolw7nt%{s!wsn#|pN#Rq zC?$#3T&@XRpd&xT;jMdahgJBUdHRXtDaNNXa{OkLS%br6#BlRl#qreT&;uptF{BAU zC=sV~OUk6HU04iDXWZ9}W62d+IsSQ|cP;WE78Un2rP=K~p@YMMb2R=ms+|+! zL7H1`mm@-y+FUCld&Oa@B!6wjGS6xy6LaO=rQV9Gp9|{#N$tc@s^aO3Xss!qi>28m zg=c5&cgP>v=EFKS4VM}g3ox&acEw0A+>ee~$NfR1wMS2d>8g;Y%0pw1drth&aQ(vV zRXYL%N`quxKYuDijh;Ho>txbKcM+LUBjxAz=?1n*E<1)EuIkm**|6!S!E>WvJ3k@x zK$?Cre`_e0AtUe@J=KZeoH!_4;U)GYqt!8v5Bk$|gL&fIM`}IT;^6Bz{%0cPC z!NbHgvQ>~Rr+4B&PYvX_l6~d^MRZGw&85rhHREx8D$$#T7>o#YlT!@odYjm_CylWC z>+@>s&qe%iWuL7j{tZ?R8tbsiC`h?Lkc;UY@l+BT`_gKv2mBU+;b_a@{vraIV-S;F zm;>c;tlyHKAC)U=lD;D*;<=zLy{pAv6&XrTBnGRGCs}2m`)Sw8pFtRi_p8=tGhdU>vC zQ_DZ_n?PP1p_@t!nD`}l4;y1-PMHk{v-qa`G-rSDdd2jqgDnqda?H!_{vxJzz(#- z`7}P}T&Mq@nfV_k8yrWxKbthTu7B=C995JQtHKwgpu%CIjT)qo>sHJ42T9&mU|sm* z{YykNq-c19MauujRN!Ia1HmBl=XkfTYUSV!S_5+SUxO`oA|*0>*e$nG$atvKU?ed0 z?0=Ju14*(Ph!RCz^h7J8@3eCyN|}MMDrW`GJnuvKCGiq3Tm~7>F6dEk_EJhmm%x?x zD~;kd52KJrfmo$;UppAAI60x1XqVJwc1y3pS4R}RFV=n1+$ZBvo)LsF3i*2$@LFS}LzF!$Seq}p1C zQNN*9K2t=vIg@QL5=q(*2|@0gL9-91%`D#*D7HW@LJGr&{sGSl0XHW{FNyN=MR1c| zhQgCSdf>Ur&0-1vHA)l3jI=(O$qrA3eE(Bht@G=PSmv$|-$8#0XvLth+Veu%BJ@Lz zo~~CQZR%IPe=QVP3H;c_K5EDsFEua#sS*^)uMC5qF{}6Wf2mLhPojavE<^MjKSpc< z322*jTY0T=71No(`L9m^Q5Dz;{H(rs>{b>?-wQx)ZFrJ}P!S5YJg^ig=y3PaUlnOF z^%7miNnOYAIA#sSdAAiTJgR>Ur#&J?0coD^J*DeGG7f1eYmpRq?m3vAVpMxkG#cqe zkR}ofQdHDacr59#g$~6&)g^s}8%dh76cxah(l$+3CIgk-%!r4zG*L#NK^f$pAeTSF6ZBd==tKl~lPY*Dd3i|@Pde>U zM<2MzA_?=xGpWf6y;KF>^4|dcLF!}==!4Pa;gSlV0RHz77c(CPh5H7x_#+u@45T}> zJ|UED{q)3mzQLX5HQWzqaBvVJh0l{3Kz$B4DIo_40R$CG^;pm<0T`v`dm<@>DVh(R z=99%*s&Bs%MfcCjEB^Onlm-%=Vp@J<7LWDxdyvrH+E9obDF>{B1Rtc|Uux+VucS)g zWh0=}2*N*mqN4hPiIE0S@(u?$eq^2PC$Vz#Wrh~a@fg9eu_44wUP!(O{iGbrZJ3-WBp^>Kt8 z;ZB1}=`Y7tCs}PhLkH3!BjT9UnEXszn85q|9tB~QBH~FSz%!7g;Y>|3#5#r4vG0`( zL-d<}F7xBdqbB2kx|qA0i^R$S0cN$3HG43tG4i0F-C|n0m#Dk*jXJ~-D+W*rB&jc5 zkk{*xTjzfkwL^#$y{`qCy)y80S97q6S_Xs1>HQD!zLlYs?zykaQ2QNFEDE9m>^u%6 z@X{B&Cy@AOQYHj&Qcrk{i2N6%wa@~P(X;Sr?+WMn^Ck8|94tDK=N4Fnt`)^ zEJ)g+uOf9@{?D>T2!}Rk?1_~o{V~*Uz93z_*L1~0Wd)B7rDV`&upq#REWk1CESd3G zj7h7PvxNLxi9tw1D^13|1bgRKf%uBjOVREOc$EG|V84_hkV(MTMqt&cjsrk$H^>;G zTI-3&U`5H``!B{Ow*c&y6|AS2SB9O^rDR?(3UOk;0Skl$9(Jg^9bU_U3JS;- zATubL*EZuBizdha7%6-SBy*WsT-*U;=tn69In(+|5oi`uLGZB+WTEUsg0Dh2Ap>7zoYvD- z9NCL?aEB24hK5PRA@(+s98_0u2-5|5iT)yl>KRCF@O`K_VZ)t1s0HkLIp(9e@Fbz$ z)$Z6>6*`Q6;Y3;(o>~3(aH)L_}Ip=4oBoF%6D7coOu^{vus zs^S=MF?2cd1ph*2ln27gw_l?`yxEy2KAq+O%M&RQ_`{)9C$XU9hn3FoX-~zg<27TD zNd0vDtJ=T5{(b5n{Zgt2%=izGwlea|a&p(mSR2?+3LLKZKBB6>OhSTK!6*T@M1Y?9 zVLI~He}@DKzqZb(#TcYd@KZ}|wP8OQC74J*T+JtN(@P@&=cf@f!ljAo0*mh$_DIO9 z48%pG8J}WdxEm6I(F7wU(%`_4psl~iEcYKhMkhh}+QI}6b7FX)ZJNi_VU+$tbYKJS zLS9@7Pyh|V&JFkAL8S%fTiGj`S{naFxF3}4X*#07d;0-N-5+km7%}jF(Qveo&{ki8 zjtD^>VN+Pa8>*(TRDwHWFmM2>R$4S9_;Jq8x+_HxJkk01Wf?krJ^!Dbf-(|xdbRwq z+vNwaougIXX?ntk#{tHKiN+>FSquK~$x?7)_Az+-QzC!0|J;rS^iBprl0Y269Uhh= zv0BH7JS#2viM_x6#i(zcJ1dMP#FI>uj~jdYD$JO6Lh=V zQ^Ke6mN%Kx!f{?BkOvG0mDQh)OipfX^h@yh?a6fqqm`F7uobry z-!u%wSk8}C|v1&xNSX6JoB0*dJ0VWC$Ys1+DhYbxeIr- zoU)^Lt5fJf3K`!w{QrgbS@=wNP5b}vj`;z%uphL02HBqqu+>69W@lYs!aCa$(g*94)2^+@}_5j!u&4^6h_{;~RM6EEj1|xF-Y}E=|b-|5(Ni zIwtrIc8Tq@n1lTnfZ7sHEqhCYLjXS7Xr{Dv=8=KEAQ-vep)vrczQB}%u4E`^ zzQjYt03Uf-6u_K61$}u8tb!B=Jkc{a8}yd07#A%t2z(?-$SpfEM*)3#l$?%)00K^@ zaJw?-E&fC(QX4RNCzhNl?6L|G^u@1F5#cf1i!zo#_bSmWB@v*&yHESyCsrs%Nqt{ruO4WOJM*EM> znLp-wn|8WbE7rpJRSM6i93;Cr-L+elLWG&ZI!}k48PtlJsz?ugGA8ITQSv|_>Ujov zVSNllK;1V7O3_#(6W!I;qMM$eOc7x;`g10kwVv_SKCT_P)=weEeMHMiIdX)(pvW1I zf7Y8Y*`M0ZujK>sGC<@@m6Tbt5ddM#tDVh??!t_9XT2eo_RmW>jkumfUB)~E??_T7 zX@8c{`iu`73C^6qoq1$#E-3gHTY!RJgFx={7qye(>5~u}b>zcek@d+s3672zF%ZI0sQI5Jo$nK|Z6A1YFfxnEqY!rjuP-rd4=PnaE5R z8S^4?VRQVhj}7~;)}-j7b)H918{K%v8=!R`6e@bD0^6kzBu@cJLzKR^<@C%(9uMGP z&VF}|CBtA%gr^J(Z0+e5l|l!izZsPMVtJ?`hVbV_-_MIgO5ohS7J z`vf^iA?au4iDO<_H-Ej+`j4_B89;dH!AQMT6zY!G-r!35>fQyhc<#_@9S!^qy?km& z@V7iKjTo1(3ahVFi`Vo^T+xDL&qO1oQjrog*5h`!Tt@h$ID*IpT4}qW&wqCsYrHeT zO3u`m^Fr`k0TjUbDq&2>l0?sCs+g$XNPafr_$II|SVLS9Y`d$0+Q%ngOEr>(#@3a+ zax)e7%wtLB7_#cAa}{}+S6$yfv;IXC?5+2gYc#w;3X1t_HW_6YsgDseqC?1kPX_2_ zcNTC$+v-)aI$r6MYE;nSs6$UcwtkPYRD4vxQvn7`Y58b>Fx$v9?s(#0{}&8W_}I3- zVXOYPmu%G2%;MKRQ~17*2-}@O^9<$81Y5Ls^Ip_TB|hZ{CfAB*D&!N(L11w)d%D_8 z)?uvpTc|X=e$Ib6#DFbk@p11Q%b**(h@hM52}ZB- z8gs9*u~G~owg^r8((VdOaL|L~yf9BoYl-@Sj1l`tdJM!m?wcZ&sm_Ejcm#W(3!}cSr&zxWz-_Xm4y-Hyx z73vT$Ri$uLgasp_x;08%UK}*9-mGCbX$8rV(lel>op{2}L~T$4h@k6!lz=l6qpiMR z6?AdSYHI{brQz~^p}@fM`*6M06y8*Wd-i$0`Y(&KT4Ue4Gm8VeH(yApXypn$9q6Dd zooC0B1~(xCWS+bB4js5}40GqpR|vJ5ygn1Zz4-OPY)*zgo!Yo3Zm^1~$@@mB!gfJK zPahEPPX_RC&iC&mMDCn2yl(c8`R^}B9F{-R%Um7PZ+mN@k#NjcZn%P?p`X5pqot!f zCAOOfSMubR9o=6RJU?{#ot4xEjCcK305C$WRk_X#G?>NPGDTSlTAtgnUoJw$bcpB=CNogHJ7%C5X`u#ZP*w|Ebj z67^}rDM)c;41|fANJYg6Fn#4Q0{*5`hft`>4m38Fra6fhZR%Al#a^uL?e{y~8Y_h- zj`Tb^8M~RbbnSClMtmwH-Ba}4kWf_UEk=M6vM8k;k{`dgi(it(ZHpa$lZmjP_PUU3 zjn~n27_L36*7*D>|7uyA{_R(Ko00Z|rvj_*wm6N`Z|iP&lSe9l-rn{*u!n0E3@>}k z1;mTnB_mm&iSyh{?N^v|24bEhP|815$9@({8`#&7bys2k>zj>EqBm2IT8yIYJv8F| zT9{xDe~mEhlQ06_;rWp?eCnPtVS^OWn`(LAs<7frLAClT2-+)_gD>}^eI`wMD&Ryq z&4H0B;!U0aejR;vm~+-!+seD~oZ;64HGU@7d>?kclZ*}GNfdr%nrZ{vlC5xw#iq4dQ*?-sIq$J1ZR-~4`e zxopu1dy4!Zxf#9z(q3u+wG0gdZE8#i#Xd9dyj#w>$9dj0w?CSO{*+dRuTRo^pDb+F z$CJ?gY+PB}&--f4L#@;p^l{zd$i=LDq~GPaMRzNht9fT=N4Q$Q%`rmGuer#2ZGwNL+I-Y6g;ErEKx-#Yd` z;;*q%Z4Sv9uIuV~m^;4UT34`b)@sF(f`M<|!CnP!{A$<(?eS#ok@4}ED!Xif3cchm zMlbUJIqau)1opcc48)`HrXLKKxQ@0vyv@65*qfVaioLjZwMM2|rY%$NNgtPF^Zb}b zsub(3MS_q0Le%{?OuT5jD zJ^+8?6e^MCH}Lz^Fz^SL zJ+@eEQ7TM?P2?H?;Y>A zxs?-GZ&a%g@qBm5G5=!u&w`sU(Pj>)T8VU~a@mw$@M_oddz^M~+JOT-+l7!ZWMdP&YjQ$|f0 z0SW}t5*m=YWBZ=;PyUdK)S;<#6nhnfK~eGttK@Up6ZoA@@!y>e4V7e^i(_jbx8i7Y zi+nHPZei5+4%dH44c|sUshTbQV6O{>T`7O`9rnuf(cWamRwecsikQNul%F;UC;RTL zR&P&XZqX>DlsOh-u`kD?O8di1JcNno?;q6B$?fCbo7sKdw&&NK@I5Zsy@pmM{xFIc z81ebxfm&FBLlEhy&yShJLZnjdeaFPEXVTV5)5cM{qUIg((<|jVe+&8QZG6DNVv?VJ zgc&^&5hoalYjwf*K4HN2=kUUL z`RHnwGq?d46PrL)YxWsELJ5jLAqx<1`yfbyqDzPk)Eqy4%Eb2m`1mLGG^z+(2x z_F-zqw~I_T$MvA=46LgRCZNmA-bAEBneMEvrbmvKwej(Elh=v-mV|Tl ziJFP4ElK+hCEh!f)6>38Q4Q=tPPCo`)>4m;32#qlfpF@F#6cS<%RSsab(o&XY24FY zf8b-~Vk!qqlE-llCow4WGz8fa3?>KYBkF}tU(#IX-=xf~cH2t2l_HATRkhE#x~jysQz;pcD@a@g-jxhg7(;AC$ao@DF5Q^%; z3WF~UD@!3a`L@J!&y_ou|70?L9~GnIR^YqYeQSZLqafUO(9145*}l`)1h^H31Fx{t zSMs_<32-za`5&6V0#z*tzJ6!yQQEd}b02k(;6rWDG+_YgTX>+ut$o#iAxuTrQt&7Z zBkA2nVf|V$3Hewf8{PioPm;Apl+p=Dey6nG{550(y(CN~7qAba#1A-1IJk7hnY;>T zUR5LEw)h$a${2O4;1{6gXORV=A2G-#?q_A?kB`;9zxzd^VHdV+yVXUa8*%al(<{ioj zYX;XqK06{x1Rka}*JDT146kGH@%AX6$cCfNnbo7NOfDH3Ae$xyX%>o~t9qS@`@+Q| zLxoG}1%)9DNY0*jX9fDUXu&CSz7Oe~4yzwZ$u+kYe1E`%Uofk)sGa4PZrptEjb|EP zTZv5kFpjYa{dCzr)J85$e(Lcj>O+5uS`Tu+kUffcxk-PdzHrp~_0CN151Fk@4I&Q{X`|}yEnJOQ)S(e6f*J_4R17m{w~(T zaO`)=B#G)-h2?4%%6X(?^$CVf*(z0f$PZU zN=1gFfjUwlDL2Q(Q+?;hg+K%KeRIKaZvvLX58EKE_{Bp(TuyPzk!@P z>D7xlBurb2$$g8;f<%)Nbjabk@A>ZOjX^4eut+}-+erYkGKFhH&UU#uqUv7!!*>+4 zC6GxpPTjK`K>$e#bJ+QLku>)zaS9iLqrFL18H9@ts$3eV9mv}A#M?jUc7g(TZ$6iu zDkHDA;D||tH0ASB{cgHDufhDd2ucs`Q0db}HhNvm)vTphYI`W3M+rzy6l*DBH@ABT zwTAJ(M?}drV7HvKQJo+D@-j^5W_Lwf=p&gTI!H4ixk!~_Z-PmYyRVASj6X)`<}>23 zzjBuWfS~g~K#)!aW)yr9y3(_KMR++Mj%H~hA7|buxWDkek%PDKA^k&yRKtfsd;af= zFU@)Eek-QLgRGXRa7TGRsZvEJF(uUp$1fhkoALSff3W5u>JA@KrQHPAUV(rP9Y7I`u3!&ZPjqaoy0ADW!3XG62n6u=P-es;)Z)ED{LT!y-72lcV}EsV)KK@Avb!RE> z0rE`qvCY@XeD)8$1)CKdqR1JuILqmX>m@D8{%A!Sz7(UM{ibXa7DLF3+JZ2OGOF5z z9~)o%U4-Qape4bur7(scXW=XlA>9Q0toGKawknl`G1F-U(*~0#FggbEQf#j!(iKP?O)B8F^!I)z$F6)k=0GCGI8KXb8P8+)aHbx7_i8+-0CBSIfKwljY} zEl1e-9KMy4t3xa8R|M8_FK7lSH>1S{B^BEMK4jJkJ)Zv5-Z9mSLeU_FrLdisO-b5x zFL1YpN%zxr$K~gINF7-i!A~`bZl+CZx91X(SO^{{39K_TL<7N(Tst<>VnyB)obYpC z)9JSaoHy*^Jdoz*m>uDNTT0Alyykrc94(0lY$C6VNp{VC}iYUXP_tO$vd!;i^_ zIX28dH607Kao!9RNbN|c?hE`O)ev$)q|nR?<57bVXefE`_M4Ec(LC9%i2jC6KoiP? zEWzv=>u3y;7J}rU&-e!8ZO_a<_P^+1u^qYqXggH=9~l6!8Y0fhes)|uQ^g!0pgiE6puX!CBQlWIN2pRsjwjU7k@iw32PWFr|Zl`1@Pa>LK7 zj?ro{^g~6;g<+euqbnot*iGZ|S2KMXpBtZtD`SWxw)+Yi37-*>uY`oES9KzxD5tuj z`K;nbu2>IVlU#BBj)V3D>8ik>jLaPdj%= zY~>S!vDia(Iu=_8dl9jJC_xNrZ0a{$h=kF3EaHyRFkvNWdl`Np@lYfFD9{zNHmPYT z_`&125!Rsr81w8v1;q4L0tgX$2$lVR=H8sI)@(DmJoGQ@+Q5x8G&zgh|0#IXfj3_h zzI@iydZOFxb8{u(XES|Tlz|;|B}~+lF#0I9ts08Y9N2=M8kzTZ)pMjviUb;PFtNF0 z_YgiPQrU~>FmZ<6k%OxQEB3fBPQI7EUiIJ|xEvQB=IZiqEEqu9)S^Kn%=6|(!=HXDa;U5npWRFtFq=c+lvcyCv zOJqs*J!?(YFpRNpC2c~HAxn~7p~VtoiLo!u*v1fI5XN4Oe$Q0b`|EwL_xVOm zHP@ME=DN=FJaf){?&rQg-}@k#F;k%KO0xMmF+)2X@-2^)%^?}+r(SJj8?*F6=68nF z4*QVu3t`gqT$7x{$%8%{)rRPx8JTI>tuVVsvrMOql*M(0_yh2cvZya1r8vxfg{|A5 z{ya-}#evl5L|ALHDZl2NOU8BW`c4#O$>;T|gz~o^#}C!^9MW^;?Rhv7e_wIQsV|p3S)8~DGjuHvPR(xK zdwSUeshMD&YO!(#%nYF`FBSbUV}ESq`n;mNCc9O!)igaVy`UA|;Wk%rYlkA7xQhr9 z-ni8#Rq&zQQSZ%5urqTQ1`&OP;oF>ZmRk;=9=Vw{JZCHCSAU4I5ffAjDlD?!DU_eH zuoBBm!82?5o<9vTY?=DU;gigKSrm^&lc4o9x!R>l{?N6RV4buTj*S&&*jlyY-Tean zL|cg;YQPf*#@;+9@||X!p0}0g%uG;omPVItQ$8(C=bgurWS5s}1Sol~SiaiDg(4%o zRv#cPrzRTB9>ye&$^@hxqDBpBg?}P8Cmq+O(Fayq#mU{-Zv5&f8=P zD_kXG(WUroLe(3QUE@#AlzqZHRNeB_1F^J@r!^bH+|!Pd6#B&KqY=r6nfa9Vh5OM! zLJ(XqW04F~WsD_|d)z&+5h3pS@rk4+&G)lfZ`)h6jSZEa#N+t5Va_M)lpPa6d6*XI)3iwlThDtoD(BQ`~9`f8GTz z)V>u8Z@RXsW0ZW9v_yfL=uQ8TB>t)wT%zzJ9>#brvbUpn2MC#ha9Ke{-nAYf^6IZtMa5&yWqivn-+~o%f;b+Q?1Z?+)6LHTYqxsg_Sx+LeF^+$LKcf}DSZ)Vo>e7zeqWF(^mtw^A1MIAg7 z8q?`^R3LR*8n)o`Y=|s8__5+UlEH2&OdQzqg3ZdLQn$Kk7Y}leXhDdNwwFpUJ!YKE zboFXBU5tSc=bm&O0V#NlB|bdtdMWFSL(Lb`h~9Ej09QQ-DgyndI6y4YkJc%SzxLys zP7C@Tq9gB=DltUtv@Bm#^>kI_j1ct?;W+zSveu;V{e7d(KsJ$lptdC3=ozXZoS!HZ)ngUa)dPF)uh%HI+TSA;; zll0By*`$!^Wh>2*OV%f=-nt15Q~CpCt5DY>=G`FiEngI25|tx_Y>3mBXd8Yv!aG86 zSAXTlRIFeO8*Z+%UTf&l+BOO}x#$>>X<8n;f6=gb78SL7e3i}_t!kgMBK|;a%-fj zR_q9jUpQ+eaatQH+1JIm00H(7L?&&9TyCB|g!gH_*~tk7;p^+=Uv~86N?Tc&tgAnZ zMlsx3=TOhmcy{C^h#o9q{K=pu_Vaql;Y-(hv$e&ld#3ZQ4Dyor)b4GBV`q1>UYUJu zBKnaS#ct+Q6$i*W3a3Zl;uizr+Qgu_HVLi4a-PMS>QhesDggWb&|bwchFe*IBA5jDG67K5xUtitN~hK(hNQimrN(M)(@-H#HV3b6TekV zh({KY~1oUdBrE}thK^XT)x8Sb5F6*^?Yoexw0g;NPpKq%KlYlCLA-=q? zdMSsz*3W)+p#c(%_&wYqN|oOB~UJQgip2a9s_3J7P6_1mKYjXoZ2 zbYpMp`S&)$Q?GcP4-B@$Epw@jp-9Cm@6^l|J=~|xnW;`7}VZRh2XMN)s z{BG#Vt4}UFcfYrR*w1^3w*>Q*QP;kg-A(b=xfM8-fL`r>9J4jI!q?*EE)}n5EeouK z<=hT<)P!{)Vdy+%j7R1H^LSR!>=OZ5I6l0iVt!kBxyc{HTJ4&~rjlsUXdU(nQH2b5 za*1U`lG_+ui0|BL2{{&X`uwEjCa&&ugIbn~vIeG?*N_DpM|gZpKJ13D1eb%*co&xd zs7Y;V7aDZ`!aB$o7ocBN5KfC<9UUycJZ^#>=+jm|JQ2-QIxA2O9;DOM=@zi%qv+Yr zIC&p3o13d1m*pEp4(l`HJgY$#w$H?yhn>R~l2k&4Af5r_LeMQcQ&| z2I6sIv&Hj0CSllk`og`w%I=imMbE)n)~75IieabE6Kd9-q@Ry;EDmGScvi;S7edEe z+M3P;7iv(>=g!H^`57*})J%Vnq83WA)V4)@H#!Mf;6(;Dt+1)ia?B~Lx-DVSbljo& z`WdTJAi;5joSx|Km*37kPnS{MwS6+1KZz~Uix6%{?#E%LGV6Ebdue?viY?tJiE(*e zY`rVZ;-Lzt(qNud{{@q}&{OA=gnRvD z=T^UhX@D_b#OIDUk5oZ(4KuYta$50nD4uiG>jaJ#Gtfh~S6V-P<2KP+tkAraTZ#9? zg!f>-b>Rn(V1&gwsZ#&?Q*3FS)ss(A@>f6T0SG$N!wM-9YqS0tRh^aUzC>8#k>k+W zCuZ6QW5C(s2EU5tXYlyNcX*eu9V!9k95;3>=7DDU$;N0b%G)0n8Zt$B>()Ioz+l8M zU$lidZX?vXXY19mX!^zk>DxVMQg;6YU4}vW=P=zk!h^xWFpiMwRtTg1niVVWfV9S_ zqr1RN>)M>0dUseOgFAI(Zjb-F((3l?TY1}@^y3B;> zPEkjQp4QGA{~X@PDziO2(#MT*S2yCJC8xzTHKZa=9jXN&cEjzJ?#Hl~UK| z>&;d@6rO(*(P_RZa&}GD#a6@nqI!?xE1mQi(txf&;J|#HVp%metKJD%xb0L3jnLSEZf8h zx`O+a2K&0^wTU6m{-^eX9G~b`>&P_EX+SWsCHubsHBm1nEu zvsZgb8(}^_T=`a&tTSFyjM#Mtq)TFZ{}b42x26po^{K5Px4(}Tk61dCC_stj7Bf|v zP}P4$)eZ2O)BY!h7i72#(?c9$gUPLE;7S6;y077)TLd*#_bUT0x$XGtshJ$W zCMNwGE9e{duEe(9qtpXja3*%$b(|sB_d!(whFv3o-tFbiqAUV%RKI|>4&ZG7YWEL- z_lHOPgJ=GiG5ABP{@=p89Fp)Z0i|)b8vp_p;(*YOq;{lS!UP9hy7rR;$BoSBk%0Bj zMJrQf4`sjEPAz7>%w`!Xl}!bqr)no|l_^h5{e#HnP~bEBCT4IdC8a%oA8TjO+kkAa zl#m4Vm3|=9rvYf3v>7W85b&!2{ochCoeUr=f)yGV)-d1)Q|V1U0djDw013VngYXgSxtIt1n7=^&_)j_e;pTf$& zzM^U&@ba&I3fBJWIfo9Q+#=-psA|c-8o>@#dr-L`s|1;r#&hX!_!9OPD|J|f4 ZhuN##i@t0#huQ^Rx@RwFm1sCb{ttr>Nmu{? From 9c7a3f99dbfa0c1c32956294771af55a659cf6a5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 29 Jul 2025 10:35:12 -0500 Subject: [PATCH 446/581] write test for certain ck scenario --- Package.resolved | 4 +- README.md | 2 +- .../CloudKit/UnsyncedRecordID.swift | 6 +- .../CloudKitTests/AssetsTests.swift | 23 +- .../CloudKitTests/CloudKitTests.swift | 12 +- .../FetchRecordZoneChangesTests.swift | 132 +++++---- .../FetchedDatabaseChangesTests.swift | 37 ++- .../FetchedDatabaseChangesTests.swift.orig | 154 ----------- .../ForeignKeyConstraintTests.swift | 207 +++++++++------ .../CloudKitTests/MergeConflictTests.swift | 43 +-- .../ReferenceViolationTests.swift | 251 +++++++++--------- Tests/SharingGRDBTests/Internal/Schema.swift | 6 +- 12 files changed, 391 insertions(+), 486 deletions(-) delete mode 100644 Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift.orig diff --git a/Package.resolved b/Package.resolved index e863e50c..c190d59d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "a5a1be26b4513dc7ec360eb56bc08a345bac6649", - "version" : "7.5.0" + "revision" : "8ba1bc9a96afc731a000fd4136dd13a5a46297bd", + "version" : "7.6.1" } }, { diff --git a/README.md b/README.md index 44d954f1..db3d6062 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ struct MyApp: App { ), database: $0.defaultDatabase, tables: [ - /* ... */ + Item.self, ] ) } diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift index 54ab8361..01622807 100644 --- a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift +++ b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift @@ -3,7 +3,7 @@ import StructuredQueriesCore // @Table("\(String.sqliteDataCloudKitSchemaName)_unsyncedRecordIDs") - package struct UnsyncedRecordID { +package struct UnsyncedRecordID: Equatable { package let recordName: String package let zoneName: String package let ownerName: String @@ -18,8 +18,8 @@ package static func find(_ recordID: CKRecord.ID) -> Where { Self.where { $0.recordName.eq(recordID.recordName) - && $0.zoneName.eq(recordID.zoneID.zoneName) - && $0.ownerName.eq(recordID.zoneID.ownerName) + && $0.zoneName.eq(recordID.zoneID.zoneName) + && $0.ownerName.eq(recordID.zoneID.ownerName) } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index dc375e02..2a6b3d2d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -63,7 +63,9 @@ extension BaseCloudKitTests { } inMemoryDataManager.storage.withValue { storage in - let url = URL(string: "file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d")! + let url = URL( + string: "file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + )! #expect(storage[url] == Data("image".utf8)) } @@ -117,12 +119,13 @@ extension BaseCloudKitTests { } inMemoryDataManager.storage.withValue { storage in - let url = URL(string: "file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf")! + let url = URL( + string: "file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf" + )! #expect(storage[url] == Data("new-image".utf8)) } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveAsset() async throws { let remindersListRecord = CKRecord( @@ -158,14 +161,12 @@ extension BaseCloudKitTests { ) .notify() - try { - try userDatabase.read { db in - let remindersListAsset = try #require( - try RemindersListAsset.find(1).fetchOne(db) - ) - #expect(remindersListAsset.coverImage == Data("image".utf8)) - } - }() + try await userDatabase.read { db in + let remindersListAsset = try #require( + try RemindersListAsset.find(1).fetchOne(db) + ) + #expect(remindersListAsset.coverImage == Data("image".utf8)) + } } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 91c8200b..b8b285ff 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -352,12 +352,12 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 ) """, tableInfo: [ [0]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "count", notNull: true, @@ -377,7 +377,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelBs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "isOn" INTEGER NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """, @@ -390,7 +390,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [1]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "isOn", notNull: true, @@ -410,7 +410,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelCs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """, @@ -430,7 +430,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [2]: TableInfo( - defaultValue: nil, + defaultValue: "\'\'", isPrimaryKey: false, name: "title", notNull: true, diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index d9b96e89..ad185004 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -148,16 +148,14 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in - let metadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).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.read { db in + let metadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).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) @@ -166,7 +164,9 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[Reminder.recordID(for: 1)], + of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ + Reminder.recordID(for: 1) + ], as: .customDump ) { """ @@ -183,23 +183,22 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in - let metadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect( - reminder == Reminder( + try await userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).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 ) - ) - } - }() + ) + } } @Test func receiveNewRecordFromCloudKit() async throws { @@ -236,16 +235,14 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in - let metadata = try #require( - try SyncMetadata.find(1, table: RemindersList.self).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")) - } - }() + try await userDatabase.read { db in + let metadata = try #require( + try SyncMetadata.find(1, table: RemindersList.self).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")) + } try await withDependencies { $0.date.now.addTimeInterval(1) @@ -281,12 +278,10 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "My stuff")) - } - }() + try await userDatabase.read { db in + let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) + #expect(remindersList == RemindersList(id: 1, title: "My stuff")) + } } @Test func receiveNewRecordFromCloudKit_ChildBeforeParent() async throws { @@ -309,7 +304,10 @@ extension BaseCloudKitTests { action: .none ) - let remindersListModification = try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + let remindersListModification = try syncEngine.modifyRecords( + scope: .private, + saving: [remindersListRecord] + ) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { @@ -347,27 +345,25 @@ extension BaseCloudKitTests { await remindersListModification.notify() - try { - try userDatabase.read { db in - let reminderMetadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) - ) - #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + try await userDatabase.read { db in + let reminderMetadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + ) + #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - let remindersListMetadata = try #require( - try SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) - ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) + let remindersListMetadata = try #require( + try SyncMetadata.find(1, table: RemindersList.self).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 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")) - } - }() + let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) + #expect(remindersList == RemindersList(id: 1, title: "Personal")) + } try await withDependencies { $0.date.now.addTimeInterval(1) @@ -413,12 +409,10 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 1)) - } - }() + 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)) + } } @Test func deleteMultipleRecords() async throws { @@ -443,12 +437,10 @@ extension BaseCloudKitTests { ) .notify() - try { - try userDatabase.read { db in - try #expect(Reminder.all.fetchCount(db) == 0) - try #expect(RemindersList.all.fetchCount(db) == 0) - } - }() + try await userDatabase.read { db in + try #expect(Reminder.all.fetchCount(db) == 0) + try #expect(RemindersList.all.fetchCount(db) == 0) + } } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift index 7dc434ea..2e9fd4af 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift @@ -26,19 +26,20 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await syncEngine.modifyRecordZones(scope: .private, deleting: [syncEngine.defaultZone.zoneID]).notify() + try await syncEngine.modifyRecordZones( + scope: .private, + deleting: [syncEngine.defaultZone.zoneID] + ).notify() try await syncEngine.processPendingDatabaseChanges(scope: .private) - try { - try userDatabase.read { db in - try #expect(Reminder.all.fetchAll(db) == []) - try #expect(RemindersList.all.fetchAll(db) == []) - try #expect(RemindersListPrivate.all.fetchAll(db) == []) - try #expect( - UnsyncedModel.all.fetchAll(db) == [UnsyncedModel(id: 1), UnsyncedModel(id: 2)] - ) - } - }() + try await userDatabase.read { db in + try #expect(Reminder.all.fetchAll(db) == []) + try #expect(RemindersList.all.fetchAll(db) == []) + try #expect(RemindersListPrivate.all.fetchAll(db) == []) + try #expect( + UnsyncedModel.all.fetchAll(db) == [UnsyncedModel(id: 1), UnsyncedModel(id: 2)] + ) + } } @Test func deleteSyncEngineZone_EncryptedDataReset() async throws { @@ -66,14 +67,12 @@ extension BaseCloudKitTests { ) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try { - try userDatabase.read { db in - try #expect(Reminder.count().fetchOne(db) == 2) - try #expect(RemindersList.count().fetchOne(db) == 2) - try #expect(RemindersListPrivate.count().fetchOne(db) == 2) - try #expect(UnsyncedModel.count().fetchOne(db) == 2) - } - }() + try await userDatabase.read { db in + try #expect(Reminder.count().fetchOne(db) == 2) + try #expect(RemindersList.count().fetchOne(db) == 2) + try #expect(RemindersListPrivate.count().fetchOne(db) == 2) + try #expect(UnsyncedModel.count().fetchOne(db) == 2) + } assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift.orig b/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift.orig deleted file mode 100644 index 1923c984..00000000 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift.orig +++ /dev/null @@ -1,154 +0,0 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import OrderedCollections -import SharingGRDB -import SnapshotTestingCustomDump -import Testing - -extension BaseCloudKitTests { - @MainActor - @Suite - final class FetchedDatabaseChangesTests: BaseCloudKitTests, @unchecked Sendable { - @Test func deleteSyncEngineZone() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - Reminder(id: 2, title: "Call accountant", remindersListID: 2) - RemindersListPrivate(id: 1, remindersListID: 1) - RemindersListPrivate(id: 2, remindersListID: 2) - UnsyncedModel(id: 1) - UnsyncedModel(id: 2) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - -<<<<<<< HEAD - try await syncEngine.modifyRecordZones(scope: .private, deleting: [SyncEngine.defaultZone.zoneID]).notify() - try await syncEngine.processPendingDatabaseChanges(scope: .private) -======= - await syncEngine.modifyRecordZones(scope: .private, deleting: [syncEngine.defaultZone.zoneID]) - await syncEngine.processPendingDatabaseChanges(scope: .private) ->>>>>>> origin/cloudkit - - try { - try userDatabase.read { db in - try #expect(Reminder.all.fetchAll(db) == []) - try #expect(RemindersList.all.fetchAll(db) == []) - try #expect(RemindersListPrivate.all.fetchAll(db) == []) - try #expect( - UnsyncedModel.all.fetchAll(db) == [UnsyncedModel(id: 1), UnsyncedModel(id: 2)] - ) - } - }() - } - - @Test func deleteSyncEngineZone_EncryptedDataReset() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - Reminder(id: 2, title: "Call accountant", remindersListID: 2) - RemindersListPrivate(id: 1, remindersListID: 1) - RemindersListPrivate(id: 2, remindersListID: 2) - UnsyncedModel(id: 1) - UnsyncedModel(id: 2) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - await syncEngine - .handleEvent( - SyncEngine.Event.fetchedDatabaseChanges( - modifications: [], - deletions: [(syncEngine.defaultZone.zoneID, .encryptedDataReset)] - ), - syncEngine: syncEngine.private - ) - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try { - try userDatabase.read { db in - try #expect(Reminder.count().fetchOne(db) == 2) - try #expect(RemindersList.count().fetchOne(db) == 2) - try #expect(RemindersListPrivate.count().fetchOne(db) == 2) - try #expect(UnsyncedModel.count().fetchOne(db) == 2) - } - }() - - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 2, - isCompleted: 0, - remindersListID: 2, - title: "Call accountant" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersListPrivates/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - position: 0, - remindersListID: 1 - ), - [3]: CKRecord( - recordID: CKRecord.ID(2:remindersListPrivates/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 2, - position: 0, - remindersListID: 2 - ), - [4]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ), - [5]: CKRecord( - recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 2, - title: "Business" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - } -} diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index 4d1546f1..50160dae 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -29,7 +29,10 @@ extension BaseCloudKitTests { action: .none ) - let remindersListModification = try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + let remindersListModification = try syncEngine.modifyRecords( + scope: .private, + saving: [remindersListRecord] + ) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { @@ -74,27 +77,25 @@ extension BaseCloudKitTests { await remindersListModification.notify() - try { - try userDatabase.read { db in - let reminderMetadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) - ) - #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + try await userDatabase.read { db in + let reminderMetadata = try #require( + try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + ) + #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - let remindersListMetadata = try #require( - try SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) - ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) + let remindersListMetadata = try #require( + try SyncMetadata.find(1, table: RemindersList.self).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 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)) - } - }() + let reminder = try #require(try Reminder.find(1).fetchOne(db)) + #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) + } try await withDependencies { $0.date.now.addTimeInterval(1) @@ -140,12 +141,70 @@ extension BaseCloudKitTests { """ } - try { - try 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)) - } - }() + 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. + */ + @Test func remoteCreatesRecordABC_localReceivesAC_remoteDeletesBC() async throws { + let modelARecord = CKRecord(recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1)) + let modelBRecord = CKRecord(recordType: ModelB.tableName, recordID: ModelB.recordID(for: 1)) + modelBRecord.setValue(1, forKey: "modelAID", at: now) + modelBRecord.parent = CKRecord.Reference(recordID: modelARecord.recordID, action: .none) + let modelCRecord = CKRecord(recordType: ModelC.tableName, recordID: ModelC.recordID(for: 1)) + modelCRecord.setValue(1, forKey: "modelBID", at: now) + modelCRecord.parent = CKRecord.Reference(recordID: modelBRecord.recordID, action: .none) + + try await syncEngine.modifyRecords(scope: .private, saving: [modelARecord]).notify() + _ = 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))] + ) + } + + try await syncEngine.modifyRecords( + scope: .private, + deleting: [modelCRecord.recordID, modelBRecord.recordID] + ) + .notify() + + try await userDatabase.read { db in + try #expect( + UnsyncedRecordID.all.fetchAll(db) == [] + ) + } + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } @Test( @@ -175,8 +234,12 @@ extension BaseCloudKitTests { _ = try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - let freshReminderRecord = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - let freshRemindersListRecord = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + let freshReminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + let freshRemindersListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) try await syncEngine.modifyRecords( scope: .private, saving: [freshReminderRecord, freshRemindersListRecord] @@ -216,8 +279,7 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in + try await userDatabase.read { db in try #expect( RemindersList.all.fetchAll(db) == [ RemindersList(id: 1, title: "Personal") @@ -229,7 +291,6 @@ extension BaseCloudKitTests { ] ) } - }() } @Test func receiveChild_Relaunch_ReceiveParent() async throws { @@ -307,8 +368,7 @@ extension BaseCloudKitTests { syncEngine: relaunchedSyncEngine.private ) - try { - try userDatabase.read { db in + try await userDatabase.read { db in let reminderMetadata = try #require( try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) @@ -327,7 +387,6 @@ extension BaseCloudKitTests { let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) #expect(remindersList == RemindersList(id: 1, title: "Personal")) } - }() try await withDependencies { $0.date.now.addTimeInterval(1) @@ -373,12 +432,10 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in + 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)) } - }() } @Test( @@ -393,14 +450,14 @@ extension BaseCloudKitTests { ) personalListRecord.setValue(1, forKey: "id", at: now) personalListRecord.setValue("Personal", forKey: "title", at: now) - + let businessListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 2) ) businessListRecord.setValue(2, forKey: "id", at: now) businessListRecord.setValue("Business", forKey: "title", at: now) - + let reminderRecord = CKRecord( recordType: Reminder.tableName, recordID: Reminder.recordID(for: 1) @@ -412,9 +469,12 @@ extension BaseCloudKitTests { recordID: RemindersList.recordID(for: 1), action: .none ) - - try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord, personalListRecord]).notify() - + + try await syncEngine.modifyRecords( + scope: .private, + saving: [reminderRecord, personalListRecord] + ).notify() + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ MockCloudContainer( @@ -447,7 +507,7 @@ extension BaseCloudKitTests { ) """ } - + let modifications = try await withDependencies { $0.date.now.addTimeInterval(1) } operation: { @@ -456,14 +516,17 @@ extension BaseCloudKitTests { ) reminderRecord.setValue(2, forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference(record: businessListRecord, action: .none) - - let modifications = try syncEngine.modifyRecords(scope: .private, saving: [businessListRecord]) + + let modifications = try syncEngine.modifyRecords( + scope: .private, + saving: [businessListRecord] + ) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() return modifications } - + await modifications.notify() - + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ MockCloudContainer( @@ -504,19 +567,17 @@ extension BaseCloudKitTests { ) """ } - - _ = try { - try 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 SyncMetadata.find(1, table: Reminder.self) - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == "2:remindersLists") - } - }() + + 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 SyncMetadata.find(1, table: Reminder.self) + .fetchOne(db) + ) + #expect(reminderMetadata.parentRecordName == "2:remindersLists") + } } @Test func changeParentRelationship_RemotelyThenLocally() async throws { @@ -558,8 +619,7 @@ extension BaseCloudKitTests { await modifications.notify() - try { - try userDatabase.read { db in + try await userDatabase.read { db in let metadata = try #require( try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) @@ -567,12 +627,13 @@ extension BaseCloudKitTests { 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) assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[Reminder.recordID(for: 1)], + of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ + Reminder.recordID(for: 1) + ], as: .customDump ) { """ @@ -589,8 +650,7 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in + try await userDatabase.read { db in let metadata = try #require( try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) @@ -598,7 +658,6 @@ extension BaseCloudKitTests { let reminder = try #require(try Reminder.find(1).fetchOne(db)) #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 3)) } - }() } @Test @@ -638,8 +697,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try { - try userDatabase.read { db in + try await userDatabase.read { db in let metadata = try #require( try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) @@ -647,12 +705,13 @@ extension BaseCloudKitTests { let reminder = try #require(try Reminder.find(1).fetchOne(db)) #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 3)) } - }() await modifications.notify() assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[Reminder.recordID(for: 1)], + of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ + Reminder.recordID(for: 1) + ], as: .customDump ) { """ @@ -672,7 +731,9 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[Reminder.recordID(for: 1)], + of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ + Reminder.recordID(for: 1) + ], as: .customDump ) { """ @@ -689,8 +750,7 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in + try await userDatabase.read { db in let metadata = try #require( try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) ) @@ -698,7 +758,6 @@ extension BaseCloudKitTests { let reminder = try #require(try Reminder.find(1).fetchOne(db)) #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 3)) } - }() } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 1466f5fd..4e426e37 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -63,7 +63,9 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(60) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { try syncEngine.modifyRecords(scope: .private, saving: [record]) }() + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() try await withDependencies { $0.date.now = now.addingTimeInterval(30) @@ -215,7 +217,9 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { try syncEngine.modifyRecords(scope: .private, saving: [record]) }() + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() try await withDependencies { $0.date.now = now.addingTimeInterval(60) @@ -326,7 +330,9 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { try syncEngine.modifyRecords(scope: .private, saving: [record]) }() + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() try await withDependencies { $0.date.now = now.addingTimeInterval(60) @@ -393,7 +399,9 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(60) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { try syncEngine.modifyRecords(scope: .private, saving: [record]) }() + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() try await withDependencies { $0.date.now = now.addingTimeInterval(30) @@ -467,7 +475,9 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { try syncEngine.modifyRecords(scope: .private, saving: [record]) }() + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() try await withDependencies { $0.date.now = now.addingTimeInterval(60) @@ -534,7 +544,9 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) let userModificationDate = now.addingTimeInterval(30) record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { try syncEngine.modifyRecords(scope: .private, saving: [record]) }() + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() try await withDependencies { $0.date.now = now.addingTimeInterval(60) @@ -608,7 +620,10 @@ extension BaseCloudKitTests { forKey: "dueDate", at: now.addingTimeInterval(1) ) - let modificationsFinished = try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + let modificationsFinished = try syncEngine.modifyRecords( + scope: .private, + saving: [reminderRecord] + ) try withDependencies { $0.date.now.addTimeInterval(2) @@ -667,20 +682,18 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect( - reminder + try await userDatabase.read { db in + let reminder = try #require(try Reminder.find(1).fetchOne(db)) + #expect( + reminder == Reminder( id: 1, dueDate: Date(timeIntervalSince1970: 30), priority: 3, remindersListID: 1 ) - ) - } - }() + ) + } } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index b43dc64e..de362b14 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -25,7 +25,10 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let modifications = try syncEngine.modifyRecords(scope: .private, deleting: [RemindersList.recordID(for: 2)]) + let modifications = try syncEngine.modifyRecords( + scope: .private, + deleting: [RemindersList.recordID(for: 2)] + ) try withDependencies { $0.date.now.addTimeInterval(1) } operation: { @@ -62,16 +65,14 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in - try #expect(Reminder.count().fetchOne(db) == 0) - try #expect( - RemindersList.all.fetchAll(db) == [ - RemindersList(id: 1, title: "Personal") - ] - ) - } - }() + try await userDatabase.read { db in + try #expect(Reminder.count().fetchOne(db) == 0) + try #expect( + RemindersList.all.fetchAll(db) == [ + RemindersList(id: 1, title: "Personal") + ] + ) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -88,10 +89,10 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try withDependencies { + try await withDependencies { $0.date.now.addTimeInterval(1) } operation: { - try userDatabase.userWrite { db in + try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) } } @@ -109,9 +110,7 @@ extension BaseCloudKitTests { recordID: RemindersList.recordID(for: 1), action: .none ) - return try { - try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) - }() + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) } try await syncEngine.processPendingRecordZoneChanges(scope: .private) await modifications.notify() @@ -149,16 +148,14 @@ extension BaseCloudKitTests { """ } - try { - try userDatabase.read { db in - try #expect( - Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] - ) - try #expect( - RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] - ) - } - }() + try await userDatabase.read { db in + try #expect( + Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] + ) + try #expect( + RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] + ) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -176,14 +173,14 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try withDependencies { + try await withDependencies { $0.date.now.addTimeInterval(1) } operation: { - try userDatabase.userWrite { db in + try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) } } - let modifications = try withDependencies { + let modifications = try await withDependencies { $0.date.now.addTimeInterval(2) } operation: { let reminderRecord = CKRecord( @@ -197,56 +194,52 @@ extension BaseCloudKitTests { recordID: RemindersList.recordID(for: 1), action: .none ) - return try { - try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) - }() + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) } await modifications.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__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:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ } - try { - try userDatabase.read { db in - try #expect( - Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] - ) - try #expect( - RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] - ) - } - }() + try await userDatabase.read { db in + try #expect( + Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] + ) + try #expect( + RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] + ) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -266,11 +259,14 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let modifications = try syncEngine.modifyRecords(scope: .private, deleting: [Parent.recordID(for: 2)]) - try withDependencies { + let modifications = try syncEngine.modifyRecords( + scope: .private, + deleting: [Parent.recordID(for: 2)] + ) + try await withDependencies { $0.date.now.addTimeInterval(1) } operation: { - try userDatabase.userWrite { db in + try await userDatabase.userWrite { db in try ChildWithOnDeleteSetNull.find(1).update { $0.parentID = 2 }.execute(db) } } @@ -282,48 +278,46 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: syncEngine.container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "childWithOnDeleteSetNulls", - parent: nil, - share: nil, - id: 1 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 - ) + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "childWithOnDeleteSetNulls", + parent: nil, + share: nil, + id: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + try await userDatabase.read { db in + try #expect( + ChildWithOnDeleteSetNull.all.fetchAll(db) == [ + ChildWithOnDeleteSetNull(id: 1, parentID: nil) + ] + ) + try #expect( + Parent.all.fetchAll(db) == [ + Parent(id: 1) ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) - ) - """ } - try { - try userDatabase.read { db in - try #expect( - ChildWithOnDeleteSetNull.all.fetchAll(db) == [ - ChildWithOnDeleteSetNull(id: 1, parentID: nil) - ] - ) - try #expect( - Parent.all.fetchAll(db) == [ - Parent(id: 1) - ] - ) - } - }() } } @@ -345,11 +339,14 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let modifications = try syncEngine.modifyRecords(scope: .private, deleting: [Parent.recordID(for: 2)]) - try withDependencies { + let modifications = try syncEngine.modifyRecords( + scope: .private, + deleting: [Parent.recordID(for: 2)] + ) + try await withDependencies { $0.date.now.addTimeInterval(1) } operation: { - try userDatabase.userWrite { db in + try await userDatabase.userWrite { db in try ChildWithOnDeleteSetDefault.find(1).update { $0.parentID = 2 }.execute(db) } } @@ -397,18 +394,16 @@ extension BaseCloudKitTests { ) """ } - try { - try userDatabase.read { db in - try #expect( - ChildWithOnDeleteSetDefault.all.fetchAll(db) == [ - ChildWithOnDeleteSetDefault(id: 1, parentID: 0) - ] - ) - try #expect( - Parent.all.fetchAll(db) == [Parent(id: 0), Parent(id: 1)] - ) - } - }() + try await userDatabase.read { db in + try #expect( + ChildWithOnDeleteSetDefault.all.fetchAll(db) == [ + ChildWithOnDeleteSetDefault(id: 1, parentID: 0) + ] + ) + try #expect( + Parent.all.fetchAll(db) == [Parent(id: 0), Parent(id: 1)] + ) + } } } } diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 0b9709d0..db60e072 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -178,14 +178,14 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql(""" CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 ) """) .execute(db) try #sql(""" CREATE TABLE "modelBs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "isOn" INTEGER NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """) @@ -193,7 +193,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql(""" CREATE TABLE "modelCs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """) From ba77edd6dc4c3668a40d73ff6c5c76c9674326c6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 29 Jul 2025 13:15:00 -0500 Subject: [PATCH 447/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 92 ++++++++++--------- .../CloudKit/UnsyncedRecordID.swift | 20 +++- .../CloudKitTests/RecordTypeTests.swift | 12 +-- 3 files changed, 72 insertions(+), 52 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 645ee773..c73c131f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -819,6 +819,51 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { + let recordIDsByRecordType = OrderedDictionary( + grouping: deletions.sorted { lhs, rhs in + guard + let lhsIndex = tablesByOrder[lhs.recordType], + let rhsIndex = tablesByOrder[rhs.recordType] + else { return true } + return lhsIndex > rhsIndex + }, + by: \.recordType + ) + .mapValues { $0.map(\.recordID) } + for (recordType, recordIDs) in recordIDsByRecordType { + let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) + if let table = tablesByName[recordType] { + func open(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.write { db in + try T + .where { + $0.primaryKey.in( + recordPrimaryKeys.map { SQLQueryExpression("\(bind: $0)") } + ) + } + .delete() + .execute(db) + + try UnsyncedRecordID + .findAll(recordIDs) + .delete() + .execute(db) + } + } + } + open(table) + } else if recordType == CKRecord.SystemType.share { + withErrorReporting { + for recordID in recordIDs { + try deleteShare(recordID: recordID) + } + } + } else { + // NB: Deleting a record from a table we do not currently recognize. + } + } + let unsyncedRecords = await withErrorReporting(.sqliteDataCloudKitFailure) { var unsyncedRecordIDs = try await userDatabase.write { db in @@ -833,9 +878,10 @@ unsyncedRecordIDs.subtract(modificationRecordIDs) if !unsyncedRecordIDsToDelete.isEmpty { try await userDatabase.write { db in - for recordID in unsyncedRecordIDsToDelete { - try UnsyncedRecordID.find(recordID).delete().execute(db) - } + try UnsyncedRecordID + .findAll(unsyncedRecordIDsToDelete) + .delete() + .execute(db) } } let results = try await syncEngine.database.records(for: Array(unsyncedRecordIDs)) @@ -902,46 +948,6 @@ } } } - - let recordIDsByRecordType = OrderedDictionary( - grouping: deletions.sorted { lhs, rhs in - guard - let lhsIndex = tablesByOrder[lhs.recordType], - let rhsIndex = tablesByOrder[rhs.recordType] - else { return true } - return lhsIndex > rhsIndex - }, - by: \.recordType - ) - .mapValues { $0.map(\.recordID) } - for (recordType, recordIDs) in recordIDsByRecordType { - let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) - if let table = tablesByName[recordType] { - func open(_: T.Type) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in - try T - .where { - $0.primaryKey.in( - recordPrimaryKeys.map { SQLQueryExpression("\(bind: $0)") } - ) - } - .delete() - .execute(db) - } - } - } - open(table) - } else if recordType == CKRecord.SystemType.share { - withErrorReporting { - for recordID in recordIDs { - try deleteShare(recordID: recordID) - } - } - } else { - // NB: Deleting a record from a table we do not currently recognize. - } - } } package func handleSentRecordZoneChanges( diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift index 01622807..dfb34db2 100644 --- a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift +++ b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift @@ -3,7 +3,7 @@ import StructuredQueriesCore // @Table("\(String.sqliteDataCloudKitSchemaName)_unsyncedRecordIDs") -package struct UnsyncedRecordID: Equatable { + package struct UnsyncedRecordID: Equatable { package let recordName: String package let zoneName: String package let ownerName: String @@ -18,8 +18,22 @@ package struct UnsyncedRecordID: Equatable { package static func find(_ recordID: CKRecord.ID) -> Where { Self.where { $0.recordName.eq(recordID.recordName) - && $0.zoneName.eq(recordID.zoneID.zoneName) - && $0.ownerName.eq(recordID.zoneID.ownerName) + && $0.zoneName.eq(recordID.zoneID.zoneName) + && $0.ownerName.eq(recordID.zoneID.ownerName) + } + } + package static func findAll(_ recordIDs: some Collection) -> Where { + let condition: QueryFragment = recordIDs.map { + "(\(bind: $0.recordName), \(bind: $0.zoneID.zoneName), \(bind: $0.zoneID.ownerName))" + } + .joined(separator: ", ") + return Self.where { + SQLQueryExpression( + """ + (\($0.recordName), \($0.zoneName), \($0.ownerName)) + IN (\(condition)) + """ + ) } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index f5dbb3f5..2f112a16 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -351,12 +351,12 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 ) """, tableInfo: [ [0]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "count", notNull: true, @@ -376,7 +376,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelBs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "isOn" INTEGER NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """, @@ -389,7 +389,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [1]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "isOn", notNull: true, @@ -409,7 +409,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelCs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """, @@ -429,7 +429,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [2]: TableInfo( - defaultValue: nil, + defaultValue: "\'\'", isPrimaryKey: false, name: "title", notNull: true, From dc25a4a40ab4c7633d1c578a58419e2edece3ae8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 29 Jul 2025 13:15:31 -0500 Subject: [PATCH 448/581] Revert "wip" This reverts commit ba77edd6dc4c3668a40d73ff6c5c76c9674326c6. --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 92 +++++++++---------- .../CloudKit/UnsyncedRecordID.swift | 20 +--- .../CloudKitTests/RecordTypeTests.swift | 12 +-- 3 files changed, 52 insertions(+), 72 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index c73c131f..645ee773 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -819,51 +819,6 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { - let recordIDsByRecordType = OrderedDictionary( - grouping: deletions.sorted { lhs, rhs in - guard - let lhsIndex = tablesByOrder[lhs.recordType], - let rhsIndex = tablesByOrder[rhs.recordType] - else { return true } - return lhsIndex > rhsIndex - }, - by: \.recordType - ) - .mapValues { $0.map(\.recordID) } - for (recordType, recordIDs) in recordIDsByRecordType { - let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) - if let table = tablesByName[recordType] { - func open(_: T.Type) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in - try T - .where { - $0.primaryKey.in( - recordPrimaryKeys.map { SQLQueryExpression("\(bind: $0)") } - ) - } - .delete() - .execute(db) - - try UnsyncedRecordID - .findAll(recordIDs) - .delete() - .execute(db) - } - } - } - open(table) - } else if recordType == CKRecord.SystemType.share { - withErrorReporting { - for recordID in recordIDs { - try deleteShare(recordID: recordID) - } - } - } else { - // NB: Deleting a record from a table we do not currently recognize. - } - } - let unsyncedRecords = await withErrorReporting(.sqliteDataCloudKitFailure) { var unsyncedRecordIDs = try await userDatabase.write { db in @@ -878,10 +833,9 @@ unsyncedRecordIDs.subtract(modificationRecordIDs) if !unsyncedRecordIDsToDelete.isEmpty { try await userDatabase.write { db in - try UnsyncedRecordID - .findAll(unsyncedRecordIDsToDelete) - .delete() - .execute(db) + for recordID in unsyncedRecordIDsToDelete { + try UnsyncedRecordID.find(recordID).delete().execute(db) + } } } let results = try await syncEngine.database.records(for: Array(unsyncedRecordIDs)) @@ -948,6 +902,46 @@ } } } + + let recordIDsByRecordType = OrderedDictionary( + grouping: deletions.sorted { lhs, rhs in + guard + let lhsIndex = tablesByOrder[lhs.recordType], + let rhsIndex = tablesByOrder[rhs.recordType] + else { return true } + return lhsIndex > rhsIndex + }, + by: \.recordType + ) + .mapValues { $0.map(\.recordID) } + for (recordType, recordIDs) in recordIDsByRecordType { + let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) + if let table = tablesByName[recordType] { + func open(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.write { db in + try T + .where { + $0.primaryKey.in( + recordPrimaryKeys.map { SQLQueryExpression("\(bind: $0)") } + ) + } + .delete() + .execute(db) + } + } + } + open(table) + } else if recordType == CKRecord.SystemType.share { + withErrorReporting { + for recordID in recordIDs { + try deleteShare(recordID: recordID) + } + } + } else { + // NB: Deleting a record from a table we do not currently recognize. + } + } } package func handleSentRecordZoneChanges( diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift index dfb34db2..01622807 100644 --- a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift +++ b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift @@ -3,7 +3,7 @@ import StructuredQueriesCore // @Table("\(String.sqliteDataCloudKitSchemaName)_unsyncedRecordIDs") - package struct UnsyncedRecordID: Equatable { +package struct UnsyncedRecordID: Equatable { package let recordName: String package let zoneName: String package let ownerName: String @@ -18,22 +18,8 @@ package static func find(_ recordID: CKRecord.ID) -> Where { Self.where { $0.recordName.eq(recordID.recordName) - && $0.zoneName.eq(recordID.zoneID.zoneName) - && $0.ownerName.eq(recordID.zoneID.ownerName) - } - } - package static func findAll(_ recordIDs: some Collection) -> Where { - let condition: QueryFragment = recordIDs.map { - "(\(bind: $0.recordName), \(bind: $0.zoneID.zoneName), \(bind: $0.zoneID.ownerName))" - } - .joined(separator: ", ") - return Self.where { - SQLQueryExpression( - """ - (\($0.recordName), \($0.zoneName), \($0.ownerName)) - IN (\(condition)) - """ - ) + && $0.zoneName.eq(recordID.zoneID.zoneName) + && $0.ownerName.eq(recordID.zoneID.ownerName) } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 2f112a16..f5dbb3f5 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -351,12 +351,12 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + "count" INTEGER NOT NULL ) """, tableInfo: [ [0]: TableInfo( - defaultValue: "0", + defaultValue: nil, isPrimaryKey: false, name: "count", notNull: true, @@ -376,7 +376,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelBs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """, @@ -389,7 +389,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [1]: TableInfo( - defaultValue: "0", + defaultValue: nil, isPrimaryKey: false, name: "isOn", notNull: true, @@ -409,7 +409,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelCs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """, @@ -429,7 +429,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [2]: TableInfo( - defaultValue: "\'\'", + defaultValue: nil, isPrimaryKey: false, name: "title", notNull: true, From ce52b360cd9e71f6f5cd76b234e9ffa76bd7a37d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 29 Jul 2025 13:15:00 -0500 Subject: [PATCH 449/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 92 ++++++++++--------- .../CloudKit/UnsyncedRecordID.swift | 20 +++- .../CloudKitTests/RecordTypeTests.swift | 12 +-- 3 files changed, 72 insertions(+), 52 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 645ee773..c73c131f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -819,6 +819,51 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { + let recordIDsByRecordType = OrderedDictionary( + grouping: deletions.sorted { lhs, rhs in + guard + let lhsIndex = tablesByOrder[lhs.recordType], + let rhsIndex = tablesByOrder[rhs.recordType] + else { return true } + return lhsIndex > rhsIndex + }, + by: \.recordType + ) + .mapValues { $0.map(\.recordID) } + for (recordType, recordIDs) in recordIDsByRecordType { + let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) + if let table = tablesByName[recordType] { + func open(_: T.Type) { + withErrorReporting(.sqliteDataCloudKitFailure) { + try userDatabase.write { db in + try T + .where { + $0.primaryKey.in( + recordPrimaryKeys.map { SQLQueryExpression("\(bind: $0)") } + ) + } + .delete() + .execute(db) + + try UnsyncedRecordID + .findAll(recordIDs) + .delete() + .execute(db) + } + } + } + open(table) + } else if recordType == CKRecord.SystemType.share { + withErrorReporting { + for recordID in recordIDs { + try deleteShare(recordID: recordID) + } + } + } else { + // NB: Deleting a record from a table we do not currently recognize. + } + } + let unsyncedRecords = await withErrorReporting(.sqliteDataCloudKitFailure) { var unsyncedRecordIDs = try await userDatabase.write { db in @@ -833,9 +878,10 @@ unsyncedRecordIDs.subtract(modificationRecordIDs) if !unsyncedRecordIDsToDelete.isEmpty { try await userDatabase.write { db in - for recordID in unsyncedRecordIDsToDelete { - try UnsyncedRecordID.find(recordID).delete().execute(db) - } + try UnsyncedRecordID + .findAll(unsyncedRecordIDsToDelete) + .delete() + .execute(db) } } let results = try await syncEngine.database.records(for: Array(unsyncedRecordIDs)) @@ -902,46 +948,6 @@ } } } - - let recordIDsByRecordType = OrderedDictionary( - grouping: deletions.sorted { lhs, rhs in - guard - let lhsIndex = tablesByOrder[lhs.recordType], - let rhsIndex = tablesByOrder[rhs.recordType] - else { return true } - return lhsIndex > rhsIndex - }, - by: \.recordType - ) - .mapValues { $0.map(\.recordID) } - for (recordType, recordIDs) in recordIDsByRecordType { - let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) - if let table = tablesByName[recordType] { - func open(_: T.Type) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in - try T - .where { - $0.primaryKey.in( - recordPrimaryKeys.map { SQLQueryExpression("\(bind: $0)") } - ) - } - .delete() - .execute(db) - } - } - } - open(table) - } else if recordType == CKRecord.SystemType.share { - withErrorReporting { - for recordID in recordIDs { - try deleteShare(recordID: recordID) - } - } - } else { - // NB: Deleting a record from a table we do not currently recognize. - } - } } package func handleSentRecordZoneChanges( diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift index 01622807..dfb34db2 100644 --- a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift +++ b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift @@ -3,7 +3,7 @@ import StructuredQueriesCore // @Table("\(String.sqliteDataCloudKitSchemaName)_unsyncedRecordIDs") -package struct UnsyncedRecordID: Equatable { + package struct UnsyncedRecordID: Equatable { package let recordName: String package let zoneName: String package let ownerName: String @@ -18,8 +18,22 @@ package struct UnsyncedRecordID: Equatable { package static func find(_ recordID: CKRecord.ID) -> Where { Self.where { $0.recordName.eq(recordID.recordName) - && $0.zoneName.eq(recordID.zoneID.zoneName) - && $0.ownerName.eq(recordID.zoneID.ownerName) + && $0.zoneName.eq(recordID.zoneID.zoneName) + && $0.ownerName.eq(recordID.zoneID.ownerName) + } + } + package static func findAll(_ recordIDs: some Collection) -> Where { + let condition: QueryFragment = recordIDs.map { + "(\(bind: $0.recordName), \(bind: $0.zoneID.zoneName), \(bind: $0.zoneID.ownerName))" + } + .joined(separator: ", ") + return Self.where { + SQLQueryExpression( + """ + (\($0.recordName), \($0.zoneName), \($0.ownerName)) + IN (\(condition)) + """ + ) } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index f5dbb3f5..2f112a16 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -351,12 +351,12 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 ) """, tableInfo: [ [0]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "count", notNull: true, @@ -376,7 +376,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelBs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "isOn" INTEGER NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """, @@ -389,7 +389,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [1]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "isOn", notNull: true, @@ -409,7 +409,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelCs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """, @@ -429,7 +429,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [2]: TableInfo( - defaultValue: nil, + defaultValue: "\'\'", isPrimaryKey: false, name: "title", notNull: true, From f0c9dbe177bd284763f672f27580825405f9af85 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 29 Jul 2025 13:44:42 -0500 Subject: [PATCH 450/581] get rid of uuid zeros --- Examples/Reminders/Schema.swift | 22 +++++++++---------- .../CloudKitTests/RecordTypeTests.swift | 12 +++++----- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 2daf79f9..db200802 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -143,9 +143,9 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "remindersLists" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), - "position" INTEGER NOT NULL DEFAULT 0, - "title" TEXT NOT NULL DEFAULT '' + "color" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT \(raw: 0x4a99_ef00), + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ ) @@ -165,13 +165,13 @@ func appDatabase() throws -> any DatabaseWriter { CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "dueDate" TEXT, - "isCompleted" INTEGER NOT NULL DEFAULT 0, - "isFlagged" INTEGER NOT NULL DEFAULT 0, - "notes" TEXT NOT NULL DEFAULT '', - "position" INTEGER NOT NULL DEFAULT 0, + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isFlagged" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "notes" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, - "remindersListID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', - "title" TEXT NOT NULL DEFAULT '', + "remindersListID" TEXT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT @@ -191,8 +191,8 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "remindersTags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "reminderID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', - "tagID" TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', + "reminderID" TEXT NOT NULL, + "tagID" TEXT NOT NULL, FOREIGN KEY("reminderID") REFERENCES "reminders"("id") ON DELETE CASCADE, FOREIGN KEY("tagID") REFERENCES "tags"("id") ON DELETE CASCADE diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index f5dbb3f5..2f112a16 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -351,12 +351,12 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 ) """, tableInfo: [ [0]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "count", notNull: true, @@ -376,7 +376,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelBs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "isOn" INTEGER NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) """, @@ -389,7 +389,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [1]: TableInfo( - defaultValue: nil, + defaultValue: "0", isPrimaryKey: false, name: "isOn", notNull: true, @@ -409,7 +409,7 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelCs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) """, @@ -429,7 +429,7 @@ extension BaseCloudKitTests { type: "INTEGER" ), [2]: TableInfo( - defaultValue: nil, + defaultValue: "\'\'", isPrimaryKey: false, name: "title", notNull: true, From de1195093eadf6c71fd0ec684295195ddbb7e0ad Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 29 Jul 2025 16:17:02 -0500 Subject: [PATCH 451/581] docs --- .../Documentation.docc/Articles/CloudKit.md | 170 +++++++++++++++++- 1 file changed, 162 insertions(+), 8 deletions(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index fca0ceb9..6b935fff 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -138,7 +138,7 @@ version. #### Primary keys -> Important: Primary keys should be globally unique identifiers, such as UUID. We further recommend +> TLDR: Primary keys should be globally unique identifiers, such as UUID. We further recommend > specifying a "NOT NULL" constraint with a "ON CONFLICT REPLACE" action. Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a @@ -189,7 +189,7 @@ CREATE TABLE "reminders" ( #### Primary keys on every table -> Important: Each synchronized table must have a single, non-compound primary key to aid in +> TLDR: Each synchronized table must have a single, non-compound primary key to aid in > synchronization, even if it is not used by your app. _Every_ table being synchronized must have a single primary key and cannot have compound primary @@ -213,7 +213,7 @@ TODO: think more about this #### Default values for columns -> Important: All columns must have a default in order to allow for multiple devices to run your +> TLDR: All columns must have a default in order to allow for multiple devices to run your > app with different versions of the schema. Your tables' schemas should be defined to provide a default for every non-null column. To see why @@ -228,7 +228,7 @@ a ``NonNullColumnMustHaveDefault`` error will be thrown. #### Unique constraints -> Important: SQLite tables cannot have "UNIQUE" constraints on their columns in order to allow +> TLDR: SQLite tables cannot have "UNIQUE" constraints on their columns in order to allow > for distributed creation of records. Tables with unique constraints on their columns, other than on the primary key, cannot be @@ -245,7 +245,7 @@ when a ``SyncEngine`` is first created. If a uniqueness constraint is detected a #### Foreign key relationships -> Important: Foreign key constraints can be enabled and you can use "ON DELETE" actions to +> TLDR: Foreign key constraints can be enabled and you can use "ON DELETE" actions to > cascade deletions. SharingGRDB can synchronize many-to-one and many-to-many relationships to CloudKit, @@ -261,7 +261,7 @@ in your schema an ``InvalidParentForeignKey`` error will be thrown when construc ## Record conflicts -> Important: Conflicts are handled automatically using a "last edit wins" strategy for each +> TLDR: Conflicts are handled automatically using a "last edit wins" strategy for each > column of the record. Conflicts between record edits will inevitably happen, and it's just a fact of dealing with @@ -276,10 +276,160 @@ the only strategy available and we feel serves the needs of the most number of p ## Backwards compatible migrations -> Important: Database migrations should be done carefully and with full backwards compatibility +> TLDR: Database migrations should be done carefully and with full backwards compatibility > in mind in order to support multiple devices running with different schema versions. - +Migrations of a distributed schema come with even more complications than what is mentioned above. +If you ship a 1.0 of your app, and then in 1.1 you add a column to a table, you will need to +contend with the fact that users of the 1.0 will be creating records without that column. This can +cause problems if your migration is not designed correctly. + +#### Adding tables + +Adding new tables to a schema is perfectly safe thing to do in a CloudKit application. If a record +from a device is synchronized to a device that does not have that table it will cache the record +for later use. Then, when a device updates to the newest version of the app and detects a new table +has been added to the schema, it will populate the table with the cached records it received. + +#### Adding columns + +> TLDR: When adding columns to a table that has already been deployed to user's devices, you will +either need to make the column nullable, or it can be "NOT NULL" but a default value must be +provided with an "ON CONFLICT REPLACE" clause. + +As an example, suppose the 1.0 of your app shipped a table for a reminders list: + +```swift +@Table +struct RemindersList { + let id: UUID + var title = "" +} +``` + +…and you created the SQL table for this like so: + +```sql +CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' +) STRICT +``` + +Next suppose in 1.1 you want to add a column to the `RemindersList` type: + +```diff + @Table + struct RemindersList { + let id: UUID + var title = "" ++ var position = 0 + } +``` + +…with the corresponding SQL migration: + +```sql +ALTER TABLE "remindersLists" +ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 +``` + +Unfortunately this schema is problematic for synchronization. When a device running the 1.0 of the +app creates a record, it will not have the `position` field. And when that synchronizes to devices +running the 1.1 of the app, the ``SyncEngine`` will attempt to run a query that is essentially this: + +```sql +INSERT INTO "remindersLists" +("id", "title", "position") +VALUES +(NULL, 'Personal', NULL) +``` + +This will generate a SQL error because the "position" column was declared as "NOT NULL", and so this +record will not properly synchronize to devices running a newer version of the app. + +The fix is to allow for inserting "NULL" values into "NOT NULL" columns by using the default of the +column. This can be done like so: + +```sql +ALTER TABLE "remindersLists" +ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 +``` + +> Important: The "ON CONFLICT REPLACE" clause must come directly after "NOT NULL" because it +> modifies that constraint. + +Now when this query is executed: + +```sql +INSERT INTO "remindersLists" +("id", "title", "position") +VALUES +(NULL, 'Personal', NULL) +``` + +…it will use 0 for the "position" column. + +Sometimes it is not possible to specify a default for a newly added column. Suppose in version 1.2 +of your app you add groups for reminders lists. This can be expressed as a new field on the +`RemindersList` type: + +```diff + @Table + struct RemindersList { + let id: UUID + var title = "" + var position = 0 ++ var remindersListGroupID: RemindersListGroup.ID + } +``` + +However, there is no sensible default that can be used for this schema. But, if you migrate your +table like so: + +```sql +ALTER TABLE "remindersLists" +ADD COLUMN "remindersListGroupID" TEXT NOT NULL +REFERENCES "remindersListGroups"("id") +``` + +…then this will be problematic when older devices create reminders lists with no +`remindersListGroupID`. In this situation you have no choice but to make the field optional in +the type: + +```diff + @Table + struct RemindersList { + let id: UUID + var title = "" + var position = 0 +- var remindersListGroupID: RemindersListGroup.ID ++ var remindersListGroupID: RemindersListGroup.ID? + } +``` + +And your migration will need to add a nullable column to the table: + +```diff + ALTER TABLE "remindersLists" +-ADD COLUMN "remindersListGroupID" TEXT NOT NULL ++ADD COLUMN "remindersListGroupID" TEXT + REFERENCES "remindersListGroups"("id") +``` + +It may be disappointing to have to weaken your domain modeling to accomodate synchronization, but +that is the unfortunate reality of a distributed schema. In order to allow multiple versions of your +schema to be run on devices so that each device can create new records and edit existing records +that all devices can see, you will need to make some compromises. + +#### Disallowed migrations + +Certain kinds of migrations are simply not allowed when synchronizing your schema to multiple +devices. They are: + +* Removing columns +* Renaming columns +* Renaming tables ## Sharing records with other iCloud users @@ -293,6 +443,10 @@ See for more information. ## Assets +> TLDR: The library packages all BLOB columns in a table into `CKAsset`s and seamlessly decodes +> `CKAsset`s back into your tables. We recommend putting large binary blobs of data in their own +> tables. + All BLOB columns in a table are automatically turned into `CKAsset`s and synchronized to CloudKit. This process is completely seamless and you do not have to take any explicit steps to support assets. From 7abda98e65b4a770a8c9dc2b9a023de9347f95f2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 29 Jul 2025 17:19:06 -0500 Subject: [PATCH 452/581] wip --- Examples/Reminders/Schema.swift | 6 ++-- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../SharingGRDBCore/CloudKit/Triggers.swift | 6 ++-- .../Documentation.docc/Articles/CloudKit.md | 21 ++++++++----- .../Articles/CloudKitSharing.md | 2 ++ .../Articles/ComparisonWithSwiftData.md | 30 +++++-------------- .../SyncEngineValidationTests.swift | 2 +- .../CloudKitTests/TriggerTests.swift | 6 ++-- 8 files changed, 35 insertions(+), 40 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index db200802..1266c9de 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -214,7 +214,7 @@ func appDatabase() throws -> any DatabaseWriter { .update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1} } .where { $0.id.eq(new.id) } } when: { _ in - !SyncEngine.isUpdatingRecord() + !SyncEngine.isSynchronizingChanges() }) .execute(db) try Reminder.createTemporaryTrigger(after: .insert { new in @@ -222,7 +222,7 @@ func appDatabase() throws -> any DatabaseWriter { .update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1} } .where { $0.id.eq(new.id) } } when: { _ in - !SyncEngine.isUpdatingRecord() + !SyncEngine.isSynchronizingChanges() }) .execute(db) try RemindersList.createTemporaryTrigger( @@ -232,7 +232,7 @@ func appDatabase() throws -> any DatabaseWriter { } } when: { _ in RemindersList.count().eq(0) - && !SyncEngine.isUpdatingRecord() + && !SyncEngine.isSynchronizingChanges() } ) .execute(db) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 645ee773..3ffad66d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -429,7 +429,7 @@ ) } - public static func isUpdatingRecord() -> SQLQueryExpression { + public static func isSynchronizingChanges() -> SQLQueryExpression { SQLQueryExpression("\(raw: DatabaseFunction.syncEngineIsUpdatingRecord.name)()") } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 3e862c4b..93f28061 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -91,7 +91,7 @@ extension SyncMetadata { after: .insert { new in Values(.didUpdate(new)) } when: { _ in - !SyncEngine.isUpdatingRecord() + !SyncEngine.isSynchronizingChanges() } ) @@ -101,7 +101,7 @@ extension SyncMetadata { after: .update { _, new in Values(.didUpdate(new)) } when: { _, _ in - !SyncEngine.isUpdatingRecord() + !SyncEngine.isSynchronizingChanges() } ) @@ -115,7 +115,7 @@ extension SyncMetadata { ?? rootServerRecord(recordName: old.recordName) )) } when: { _ in - !SyncEngine.isUpdatingRecord() + !SyncEngine.isSynchronizingChanges() } ) } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 6b935fff..13761b36 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -10,7 +10,7 @@ to CloudKit. However, distributing your app's schema across many devices is an i to make, and so an abundance of care must be taken to make sure all devices remain consistent and capable of communicating with each other. Please read the documentation closely and thoroughly to make sure you understand how to best prepare your app for cloud synchronization. - + - [Setting up your project](#Setting-up-your-project) - [Setting up a SyncEngine](#Setting-up-a-SyncEngine) - [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) @@ -19,6 +19,9 @@ to make sure you understand how to best prepare your app for cloud synchronizati - [Foreign key relationships](#Foreign-key-relationships) - [Record conflicts](#Record-conflicts) - [Backwards compatible migrations](#Backwards-compatible-migrations) + - [Adding tables](#Adding-tables) + - [Adding columns](#Adding-columns) + - [Disallowed migrations](#Disallowed-migrations) - [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) - [Assets](#Assets) - [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) @@ -32,7 +35,7 @@ to make sure you understand how to best prepare your app for cloud synchronizati - [Tips and tricks](#Tips-and-tricks) - [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization) - [Topics](#Topics) -- [Go deeper](#Go-deeper) + - [Go deeper](#Go-deeper) ## Setting up your project @@ -584,15 +587,16 @@ code or from the sync engine. For example, if you have a trigger that refreshes timestamp on a row when it is edited, it would not be appropriate to do that when the sync engine updates a row from data received from CloudKit. -To prevent this you can use the ``SyncEngine/isUpdatingRecord()`` SQL expression. It represents -a custom database function that is installed in your database connection, and it will return true -if the write to your database originates from the sync engine. You can use it in a trigger like so: +To prevent this you can use the ``SyncEngine/isSynchronizingChanges()`` SQL expression. It +represents a custom database function that is installed in your database connection, and it will +return true if the write to your database originates from the sync engine. You can use it in a +trigger like so: ```swift #sql(""" CREATE TEMPORARY TRIGGER "…" AFTER DELETE ON "…"" - FOR EACH ROW WHEN NOT \(SyncEngine.isUpdatingRecord()) + FOR EACH ROW WHEN NOT \(SyncEngine.isSynchronizingChanges()) BEGIN … END @@ -609,11 +613,14 @@ Model.createTemporaryTrigger( after: .insert { new in … } when: { _ in - !SyncEngine.isUpdatingRecord() + !SyncEngine.isSynchronizingChanges() } ) ``` +This will skip the trigger's action when the row is being updated due to data being synchronized +from CloudKit. + ## Topics ### Go deeper diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md index 4b90e9c1..4072ba3a 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md @@ -413,3 +413,5 @@ struct MyApp: App { This table will still be synchronized across all of a single user's devices, but if that user shares a list with a friend, it will _not_ share the private table, allowing each user to have their own personal ordering of lists. + +## Querying share metadata diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md index adb90cc6..6934e915 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -846,34 +846,20 @@ SwiftData also has a few limitations in what features you are allowed to use in * All properties on a model must be optional or have a default value. * All relationships must be optional. -SharingGRDB has the first two limitations due to the nature of a distributed nature of the app's -schema: +SharingGRDB has only one of these limitations: * Unique constraints on columns (except for the primary key) cannot be upheld on a distributed schema. For example, if you have a `Tag` table with a unique `title` column, then what are you to do if two different devices create a tag with the title "family" at the same time? -* Properties on models must have a default. To see why this is necessary, consider if device A is -running with a schema in which `Reminder` has an `isFlagged` column and device B is running with a -schema that does not. When device B creates a record without the `isFlagged` value, and that record -is synchronized to device A, it will fail to insert into the database because there is not value -for `isFlagged`. - -However, SharingGRDB does not have the third limitation. Relationships can be non-optional since -they are modeled as simple foreign keys: - -```swift -@Table -struct Reminder { - … - var remindersListID: RemindersList.ID -} -``` - -This foreign key does not need to be optional because it can be synchronized without having -fetched the full reminders list that a reminder belongs to. +* Columns on freshly created tables do not need to have default values or be nullable. Only +newly added columns to existing tables need to either be nullable or have a default. See + for more info. +* Relationships on freshly created do not need to be nullable. Only newly added columns to +existing tables need to be nullable. See for more info. For more information about requirements of your schema in order to use CloudKit synchronization, -see , and for more general +see and +, and for more general information about CloudKit synchronization, see . ### Supported Apple platforms diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 6bfb575a..0298c8c9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -136,7 +136,7 @@ extension BaseCloudKitTests { #expect( error.localizedDescription.hasPrefix( """ - Triggers must include 'sqlitedata_icloud_syncEngineIsUpdatingRecord()' check: \ + Triggers must include 'sqlitedata_icloud_syncEngineIsSynchronizingChanges()' check: \ 'non_temporary_trigger', 'temporary_trigger' """ ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 210541c8..acee233c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -19,7 +19,7 @@ extension BaseCloudKitTests { [0]: """ CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" AFTER DELETE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsUpdatingRecord()) BEGIN + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN SELECT sqlitedata_icloud_didDelete("old"."recordName", coalesce("old"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" @@ -39,7 +39,7 @@ extension BaseCloudKitTests { [1]: """ CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsUpdatingRecord()) BEGIN + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" @@ -59,7 +59,7 @@ extension BaseCloudKitTests { [2]: """ CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsUpdatingRecord()) BEGIN + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" From 91d9ac949ee3ecc6d087572b30c1b352444490e1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 30 Jul 2025 08:06:47 -0500 Subject: [PATCH 453/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 8 ++++---- Sources/SharingGRDBCore/Internal/UserDatabase.swift | 8 ++++---- Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 3ffad66d..700b8cdd 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -133,7 +133,7 @@ ) } - @TaskLocal package static var _isUpdatingRecord = false + @TaskLocal package static var _isSynchronizingChanges = false package func setUpSyncEngine() async throws { try await setUpSyncEngine(userDatabase: userDatabase, metadatabase: metadatabase)?.value @@ -249,7 +249,7 @@ } try userDatabase.write { db in - try Self.$_isUpdatingRecord.withValue(false) { + try Self.$_isSynchronizingChanges.withValue(false) { for tableName in newTableNames { guard let table = tablesByName[tableName] else { continue } @@ -1001,7 +1001,7 @@ else { continue } func open(_: T.Type) throws { try userDatabase.write { db in - try Self.$_isUpdatingRecord.withValue(false) { + try Self.$_isSynchronizingChanges.withValue(false) { switch foreignKey.onDelete { case .cascade: try T @@ -1382,7 +1382,7 @@ fileprivate static var syncEngineIsUpdatingRecord: Self { Self(.sqliteDataCloudKitSchemaName + "_" + "syncEngineIsUpdatingRecord", argumentCount: 0) { _ in - SyncEngine._isUpdatingRecord + SyncEngine._isSynchronizingChanges } } diff --git a/Sources/SharingGRDBCore/Internal/UserDatabase.swift b/Sources/SharingGRDBCore/Internal/UserDatabase.swift index ebf00f7a..afe37c76 100644 --- a/Sources/SharingGRDBCore/Internal/UserDatabase.swift +++ b/Sources/SharingGRDBCore/Internal/UserDatabase.swift @@ -17,7 +17,7 @@ package struct UserDatabase { ) async throws -> T { try await withEscapedDependencies { dependencies in try await database.write { db in - try SyncEngine.$_isUpdatingRecord.withValue(true) { + try SyncEngine.$_isSynchronizingChanges.withValue(true) { try dependencies.yield { try updates(db) } @@ -31,7 +31,7 @@ package struct UserDatabase { ) async throws -> T { try await withEscapedDependencies { dependencies in try await database.read { db in - try SyncEngine.$_isUpdatingRecord.withValue(true) { + try SyncEngine.$_isSynchronizingChanges.withValue(true) { try dependencies.yield { try updates(db) } @@ -46,7 +46,7 @@ package struct UserDatabase { ) throws -> T { try withEscapedDependencies { dependencies in try database.write { db in - try SyncEngine.$_isUpdatingRecord.withValue(true) { + try SyncEngine.$_isSynchronizingChanges.withValue(true) { try dependencies.yield { try updates(db) } @@ -61,7 +61,7 @@ package struct UserDatabase { ) throws -> T { try withEscapedDependencies { dependencies in try database.read { db in - try SyncEngine.$_isUpdatingRecord.withValue(true) { + try SyncEngine.$_isSynchronizingChanges.withValue(true) { try dependencies.yield { try updates(db) } diff --git a/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift b/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift index 00540bfb..4a1c7ea6 100644 --- a/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift @@ -6,7 +6,7 @@ extension UserDatabase { _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await write { db in - try SyncEngine.$_isUpdatingRecord.withValue(false) { + try SyncEngine.$_isSynchronizingChanges.withValue(false) { try updates(db) } } @@ -16,7 +16,7 @@ extension UserDatabase { _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await read { db in - try SyncEngine.$_isUpdatingRecord.withValue(false) { + try SyncEngine.$_isSynchronizingChanges.withValue(false) { try updates(db) } } @@ -27,7 +27,7 @@ extension UserDatabase { _ updates: (Database) throws -> T ) throws -> T { try write { db in - try SyncEngine.$_isUpdatingRecord.withValue(false) { + try SyncEngine.$_isSynchronizingChanges.withValue(false) { try updates(db) } } @@ -38,7 +38,7 @@ extension UserDatabase { _ updates: (Database) throws -> T ) throws -> T { try write { db in - try SyncEngine.$_isUpdatingRecord.withValue(false) { + try SyncEngine.$_isSynchronizingChanges.withValue(false) { try updates(db) } } From 6519f176192bee69f4445c60e1e55ddc497401b8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 30 Jul 2025 08:18:40 -0500 Subject: [PATCH 454/581] Public method for fetching metadata. --- .../CloudKit/SyncMetadata.swift | 269 +++++++++--------- .../Documentation.docc/Articles/CloudKit.md | 25 +- 2 files changed, 153 insertions(+), 141 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 42e6b07d..d8fe250a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -1,149 +1,158 @@ #if canImport(CloudKit) -import CloudKit - -/// A table that tracks metadata related to synchronized data. -/// -/// Each row of this table represents a synchronized record across all tables synchronized with -/// CloudKit. This means that the sum of the count of rows across all synchronized tables in your -/// application is the number of rows this one single table holds. However, this table is held -/// in a database separate from your app's database. -/// -/// -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") -public struct SyncMetadata: Hashable, Sendable { - /// The unique identifier of the record synchronized. - public var recordPrimaryKey: String - - /// The type of the record synchronized, _i.e._ its table name. - public var recordType: String - - /// The name of the record synchronized. - /// - /// This field encodes both the table name and primary key of the record synchronized in - /// the format "primaryKey:tableName", for example: - /// - /// ```swift - /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" - /// ``` - // @Column(generated: .virtual) - public let recordName: String - - /// The unique identifier of this record's parent, if any. - public var parentRecordPrimaryKey: String? + import CloudKit - /// The type of this record's parent, _i.e._ its table name, if any. - public var parentRecordType: String? - - /// The name of this record's parent, if any. + /// A table that tracks metadata related to synchronized data. /// - /// This field encodes both the table name and primary key of the parent record in the format - /// "primaryKey:tableName", for example: + /// Each row of this table represents a synchronized record across all tables synchronized with + /// CloudKit. This means that the sum of the count of rows across all synchronized tables in your + /// application is the number of rows this one single table holds. However, this table is held + /// in a database separate from your app's database. /// - /// ```swift - /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" - /// ``` - // @Column(generated: .virtual) - public let parentRecordName: String? - - /// The last known `CKRecord` received from the server. /// - /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`. - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - public var lastKnownServerRecord: CKRecord? - - /// The last known `CKRecord` received from the server with all fields archived. - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - package var _lastKnownServerRecordAllFields: CKRecord? - - /// The `CKShare` associated with this record, if it is shared. - // @Column(as: CKShare?.SystemFieldsRepresentation.self) - public var share: CKShare? - - // @Column(generated: .virtual) - public let isShared: Bool - - /// The date the user last modified the record. - public var userModificationDate: Date - - package init( - recordPrimaryKey: String, - recordType: String, - parentRecordPrimaryKey: String? = nil, - parentRecordType: String? = nil, - lastKnownServerRecord: CKRecord? = nil, - _lastKnownServerRecordAllFields: CKRecord? = nil, - share: CKShare? = nil, - userModificationDate: Date - ) { - self.recordPrimaryKey = recordPrimaryKey - self.recordType = recordType - self.recordName = "\(recordPrimaryKey):\(recordType)" - self.parentRecordPrimaryKey = parentRecordPrimaryKey - self.parentRecordType = parentRecordType - if let parentRecordPrimaryKey, let parentRecordType { - self.parentRecordName = "\(parentRecordPrimaryKey):\(parentRecordType)" - } else { - self.parentRecordName = nil - } - self.lastKnownServerRecord = lastKnownServerRecord - self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields - self.share = share - self.isShared = share != nil - self.userModificationDate = userModificationDate - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") + public struct SyncMetadata: Hashable, Sendable { + /// The unique identifier of the record synchronized. + public var recordPrimaryKey: String + + /// The type of the record synchronized, _i.e._ its table name. + public var recordType: String + + /// The name of the record synchronized. + /// + /// This field encodes both the table name and primary key of the record synchronized in + /// the format "primaryKey:tableName", for example: + /// + /// ```swift + /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" + /// ``` + // @Column(generated: .virtual) + public let recordName: String + + /// The unique identifier of this record's parent, if any. + public var parentRecordPrimaryKey: String? + + /// The type of this record's parent, _i.e._ its table name, if any. + public var parentRecordType: String? + + /// The name of this record's parent, if any. + /// + /// This field encodes both the table name and primary key of the parent record in the format + /// "primaryKey:tableName", for example: + /// + /// ```swift + /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" + /// ``` + // @Column(generated: .virtual) + public let parentRecordName: String? + + /// The last known `CKRecord` received from the server. + /// + /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`. + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + public var lastKnownServerRecord: CKRecord? - // @Selection @Table - struct AncestorMetadata { - let recordName: String - let parentRecordName: String? + /// The last known `CKRecord` received from the server with all fields archived. // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - let lastKnownServerRecord: CKRecord? + package var _lastKnownServerRecordAllFields: CKRecord? + + /// The `CKShare` associated with this record, if it is shared. + // @Column(as: CKShare?.SystemFieldsRepresentation.self) + public var share: CKShare? + + // @Column(generated: .virtual) + public let isShared: Bool + + /// The date the user last modified the record. + public var userModificationDate: Date + + package init( + recordPrimaryKey: String, + recordType: String, + parentRecordPrimaryKey: String? = nil, + parentRecordType: String? = nil, + lastKnownServerRecord: CKRecord? = nil, + _lastKnownServerRecordAllFields: CKRecord? = nil, + share: CKShare? = nil, + userModificationDate: Date + ) { + self.recordPrimaryKey = recordPrimaryKey + self.recordType = recordType + self.recordName = "\(recordPrimaryKey):\(recordType)" + self.parentRecordPrimaryKey = parentRecordPrimaryKey + self.parentRecordType = parentRecordType + if let parentRecordPrimaryKey, let parentRecordType { + self.parentRecordName = "\(parentRecordPrimaryKey):\(parentRecordType)" + } else { + self.parentRecordName = nil + } + self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields + self.share = share + self.isShared = share != nil + self.userModificationDate = userModificationDate + } + + // @Selection @Table + struct AncestorMetadata { + let recordName: String + let parentRecordName: String? + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let lastKnownServerRecord: CKRecord? + } } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - package static func find( - _ primaryKey: T.PrimaryKey.QueryOutput, - table _: T.Type, - ) -> Where { - Self.where { - SQLQueryExpression( - """ - \($0.recordPrimaryKey) = \(T.PrimaryKey(queryOutput: primaryKey)) \ - AND \($0.recordType) = \(bind: T.tableName) - """ - ) + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { + package static func find( + _ primaryKey: T.PrimaryKey.QueryOutput, + table _: T.Type, + ) + -> Where + where T.PrimaryKey: IdentifierStringConvertible { + T.metadata(for: primaryKey) } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { - /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. - /// - /// - Parameter id: The ID of the record. - package static func recordName(for id: PrimaryKey.QueryOutput) -> String { - "\(id.rawIdentifier):\(tableName)" + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { + public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where { + SyncMetadata.where { + SQLQueryExpression( + """ + \($0.recordPrimaryKey) = \(PrimaryKey(queryOutput: primaryKey)) \ + AND \($0.recordType) = \(bind: tableName) + """ + ) + } + } } - var recordName: String { - Self.recordName(for: self[keyPath: Self.columns.primaryKey.keyPath]) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { + /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. + /// + /// - Parameter id: The ID of the record. + package static func recordName(for id: PrimaryKey.QueryOutput) -> String { + "\(id.rawIdentifier):\(tableName)" + } + + var recordName: String { + Self.recordName(for: self[keyPath: Self.columns.primaryKey.keyPath]) + } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { - public var recordName: some QueryExpression { - _recordName + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { + public var recordName: some QueryExpression { + _recordName + } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTableDefinition { - var _recordName: some QueryExpression { - SQLQueryExpression("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTableDefinition { + var _recordName: some QueryExpression { + SQLQueryExpression("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") + } } -} #endif diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 13761b36..aaeb2f4d 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -500,21 +500,25 @@ exposed for you to query it in whichever way you want. to attach the metadatabase to your database connection. This can be done with the ``GRDB/Database/attachMetadatabase(containerIdentifier:)`` method defined on `Database`. +With that done you can use the ``StructuredQueriesCore/PrimaryKeyedTable/metadata(for:)`` method +to construct a SQL query for fetching the meta data associated with one of your records. + For example, if you want to retrieve the `CKRecord` that is associated with a particular row in one of your tables, say a reminder, then you can use ``SyncMetadata/lastKnownServerRecord`` to retreive the `CKRecord` and then invoke a CloudKit database function to retreive all of the details: ```swift -let metadata = try database.read { db in - try SyncMetadata - .find(RemindersList.recordName(for: remindersListID)) +let lastKnownServerRecord = try database.read { db in + try RemindersList + .metadata(for: remindersListID) + .select(\.lastKnownServerRecord) .fetchOne(db) } -guard let metadata +guard let lastKnownServerRecord else { return } let ckRecord = try await container.privateCloudDatabase - .record(for: metadata.lastKnownServerRecord.recordID) + .record(for: lastKnownServerRecord.recordID) ``` > Important: In the above snippet we are explicitly using `privateCloudDatabase`, but that is @@ -530,14 +534,13 @@ It is also possible to fetch the `CKShare` associated with a record if it has be will give you access to the most current list of paricipants and permissions for the shared record: ```swift -let metadata = try database.read { db in - try SyncMetadata - .find(RemindersList.recordName(for: remindersListID)) +let share = try database.read { db in + try RemindersList + .metadata(for: remindersListID) + .select(\.share) .fetchOne(db) } -guard - let metadata, - let share = metadata.share +guard let share else { return } let ckRecord = try await container.sharedCloudDatabase From 52d05c001d34483f4a316c4ccbee00b2a210475b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 30 Jul 2025 08:18:57 -0500 Subject: [PATCH 455/581] Revert "Public method for fetching metadata." This reverts commit 6519f176192bee69f4445c60e1e55ddc497401b8. --- .../CloudKit/SyncMetadata.swift | 269 +++++++++--------- .../Documentation.docc/Articles/CloudKit.md | 25 +- 2 files changed, 141 insertions(+), 153 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index d8fe250a..42e6b07d 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -1,158 +1,149 @@ #if canImport(CloudKit) - import CloudKit - - /// A table that tracks metadata related to synchronized data. - /// - /// Each row of this table represents a synchronized record across all tables synchronized with - /// CloudKit. This means that the sum of the count of rows across all synchronized tables in your - /// application is the number of rows this one single table holds. However, this table is held - /// in a database separate from your app's database. +import CloudKit + +/// A table that tracks metadata related to synchronized data. +/// +/// Each row of this table represents a synchronized record across all tables synchronized with +/// CloudKit. This means that the sum of the count of rows across all synchronized tables in your +/// application is the number of rows this one single table holds. However, this table is held +/// in a database separate from your app's database. +/// +/// +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") +public struct SyncMetadata: Hashable, Sendable { + /// The unique identifier of the record synchronized. + public var recordPrimaryKey: String + + /// The type of the record synchronized, _i.e._ its table name. + public var recordType: String + + /// The name of the record synchronized. /// + /// This field encodes both the table name and primary key of the record synchronized in + /// the format "primaryKey:tableName", for example: /// - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") - public struct SyncMetadata: Hashable, Sendable { - /// The unique identifier of the record synchronized. - public var recordPrimaryKey: String - - /// The type of the record synchronized, _i.e._ its table name. - public var recordType: String - - /// The name of the record synchronized. - /// - /// This field encodes both the table name and primary key of the record synchronized in - /// the format "primaryKey:tableName", for example: - /// - /// ```swift - /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" - /// ``` - // @Column(generated: .virtual) - public let recordName: String - - /// The unique identifier of this record's parent, if any. - public var parentRecordPrimaryKey: String? - - /// The type of this record's parent, _i.e._ its table name, if any. - public var parentRecordType: String? - - /// The name of this record's parent, if any. - /// - /// This field encodes both the table name and primary key of the parent record in the format - /// "primaryKey:tableName", for example: - /// - /// ```swift - /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" - /// ``` - // @Column(generated: .virtual) - public let parentRecordName: String? - - /// The last known `CKRecord` received from the server. - /// - /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`. - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - public var lastKnownServerRecord: CKRecord? + /// ```swift + /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" + /// ``` + // @Column(generated: .virtual) + public let recordName: String - /// The last known `CKRecord` received from the server with all fields archived. - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - package var _lastKnownServerRecordAllFields: CKRecord? - - /// The `CKShare` associated with this record, if it is shared. - // @Column(as: CKShare?.SystemFieldsRepresentation.self) - public var share: CKShare? - - // @Column(generated: .virtual) - public let isShared: Bool - - /// The date the user last modified the record. - public var userModificationDate: Date - - package init( - recordPrimaryKey: String, - recordType: String, - parentRecordPrimaryKey: String? = nil, - parentRecordType: String? = nil, - lastKnownServerRecord: CKRecord? = nil, - _lastKnownServerRecordAllFields: CKRecord? = nil, - share: CKShare? = nil, - userModificationDate: Date - ) { - self.recordPrimaryKey = recordPrimaryKey - self.recordType = recordType - self.recordName = "\(recordPrimaryKey):\(recordType)" - self.parentRecordPrimaryKey = parentRecordPrimaryKey - self.parentRecordType = parentRecordType - if let parentRecordPrimaryKey, let parentRecordType { - self.parentRecordName = "\(parentRecordPrimaryKey):\(parentRecordType)" - } else { - self.parentRecordName = nil - } - self.lastKnownServerRecord = lastKnownServerRecord - self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields - self.share = share - self.isShared = share != nil - self.userModificationDate = userModificationDate - } + /// The unique identifier of this record's parent, if any. + public var parentRecordPrimaryKey: String? - // @Selection @Table - struct AncestorMetadata { - let recordName: String - let parentRecordName: String? - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - let lastKnownServerRecord: CKRecord? - } - } + /// The type of this record's parent, _i.e._ its table name, if any. + public var parentRecordType: String? + + /// The name of this record's parent, if any. + /// + /// This field encodes both the table name and primary key of the parent record in the format + /// "primaryKey:tableName", for example: + /// + /// ```swift + /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" + /// ``` + // @Column(generated: .virtual) + public let parentRecordName: String? - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncMetadata { - package static func find( - _ primaryKey: T.PrimaryKey.QueryOutput, - table _: T.Type, - ) - -> Where - where T.PrimaryKey: IdentifierStringConvertible { - T.metadata(for: primaryKey) + /// The last known `CKRecord` received from the server. + /// + /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`. + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + public var lastKnownServerRecord: CKRecord? + + /// The last known `CKRecord` received from the server with all fields archived. + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + package var _lastKnownServerRecordAllFields: CKRecord? + + /// The `CKShare` associated with this record, if it is shared. + // @Column(as: CKShare?.SystemFieldsRepresentation.self) + public var share: CKShare? + + // @Column(generated: .virtual) + public let isShared: Bool + + /// The date the user last modified the record. + public var userModificationDate: Date + + package init( + recordPrimaryKey: String, + recordType: String, + parentRecordPrimaryKey: String? = nil, + parentRecordType: String? = nil, + lastKnownServerRecord: CKRecord? = nil, + _lastKnownServerRecordAllFields: CKRecord? = nil, + share: CKShare? = nil, + userModificationDate: Date + ) { + self.recordPrimaryKey = recordPrimaryKey + self.recordType = recordType + self.recordName = "\(recordPrimaryKey):\(recordType)" + self.parentRecordPrimaryKey = parentRecordPrimaryKey + self.parentRecordType = parentRecordType + if let parentRecordPrimaryKey, let parentRecordType { + self.parentRecordName = "\(parentRecordPrimaryKey):\(parentRecordType)" + } else { + self.parentRecordName = nil } + self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields + self.share = share + self.isShared = share != nil + self.userModificationDate = userModificationDate } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { - public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where { - SyncMetadata.where { - SQLQueryExpression( - """ - \($0.recordPrimaryKey) = \(PrimaryKey(queryOutput: primaryKey)) \ - AND \($0.recordType) = \(bind: tableName) - """ - ) - } + // @Selection @Table + struct AncestorMetadata { + let recordName: String + let parentRecordName: String? + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let lastKnownServerRecord: CKRecord? + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncMetadata { + package static func find( + _ primaryKey: T.PrimaryKey.QueryOutput, + table _: T.Type, + ) -> Where { + Self.where { + SQLQueryExpression( + """ + \($0.recordPrimaryKey) = \(T.PrimaryKey(queryOutput: primaryKey)) \ + AND \($0.recordType) = \(bind: T.tableName) + """ + ) } } +} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { - /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. - /// - /// - Parameter id: The ID of the record. - package static func recordName(for id: PrimaryKey.QueryOutput) -> String { - "\(id.rawIdentifier):\(tableName)" - } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { + /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. + /// + /// - Parameter id: The ID of the record. + package static func recordName(for id: PrimaryKey.QueryOutput) -> String { + "\(id.rawIdentifier):\(tableName)" + } - var recordName: String { - Self.recordName(for: self[keyPath: Self.columns.primaryKey.keyPath]) - } + var recordName: String { + Self.recordName(for: self[keyPath: Self.columns.primaryKey.keyPath]) } +} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { - public var recordName: some QueryExpression { - _recordName - } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { + public var recordName: some QueryExpression { + _recordName } +} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension PrimaryKeyedTableDefinition { - var _recordName: some QueryExpression { - SQLQueryExpression("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") - } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension PrimaryKeyedTableDefinition { + var _recordName: some QueryExpression { + SQLQueryExpression("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") } +} #endif diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index aaeb2f4d..13761b36 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -500,25 +500,21 @@ exposed for you to query it in whichever way you want. to attach the metadatabase to your database connection. This can be done with the ``GRDB/Database/attachMetadatabase(containerIdentifier:)`` method defined on `Database`. -With that done you can use the ``StructuredQueriesCore/PrimaryKeyedTable/metadata(for:)`` method -to construct a SQL query for fetching the meta data associated with one of your records. - For example, if you want to retrieve the `CKRecord` that is associated with a particular row in one of your tables, say a reminder, then you can use ``SyncMetadata/lastKnownServerRecord`` to retreive the `CKRecord` and then invoke a CloudKit database function to retreive all of the details: ```swift -let lastKnownServerRecord = try database.read { db in - try RemindersList - .metadata(for: remindersListID) - .select(\.lastKnownServerRecord) +let metadata = try database.read { db in + try SyncMetadata + .find(RemindersList.recordName(for: remindersListID)) .fetchOne(db) } -guard let lastKnownServerRecord +guard let metadata else { return } let ckRecord = try await container.privateCloudDatabase - .record(for: lastKnownServerRecord.recordID) + .record(for: metadata.lastKnownServerRecord.recordID) ``` > Important: In the above snippet we are explicitly using `privateCloudDatabase`, but that is @@ -534,13 +530,14 @@ It is also possible to fetch the `CKShare` associated with a record if it has be will give you access to the most current list of paricipants and permissions for the shared record: ```swift -let share = try database.read { db in - try RemindersList - .metadata(for: remindersListID) - .select(\.share) +let metadata = try database.read { db in + try SyncMetadata + .find(RemindersList.recordName(for: remindersListID)) .fetchOne(db) } -guard let share +guard + let metadata, + let share = metadata.share else { return } let ckRecord = try await container.sharedCloudDatabase From 02563fdc3c2f8d8b04a5698749bfb458339c26c2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 30 Jul 2025 08:18:40 -0500 Subject: [PATCH 456/581] Public method for fetching metadata. --- .../CloudKit/SyncMetadata.swift | 269 +++++++++--------- .../Documentation.docc/Articles/CloudKit.md | 25 +- 2 files changed, 153 insertions(+), 141 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 42e6b07d..d8fe250a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -1,149 +1,158 @@ #if canImport(CloudKit) -import CloudKit - -/// A table that tracks metadata related to synchronized data. -/// -/// Each row of this table represents a synchronized record across all tables synchronized with -/// CloudKit. This means that the sum of the count of rows across all synchronized tables in your -/// application is the number of rows this one single table holds. However, this table is held -/// in a database separate from your app's database. -/// -/// -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") -public struct SyncMetadata: Hashable, Sendable { - /// The unique identifier of the record synchronized. - public var recordPrimaryKey: String - - /// The type of the record synchronized, _i.e._ its table name. - public var recordType: String - - /// The name of the record synchronized. - /// - /// This field encodes both the table name and primary key of the record synchronized in - /// the format "primaryKey:tableName", for example: - /// - /// ```swift - /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" - /// ``` - // @Column(generated: .virtual) - public let recordName: String - - /// The unique identifier of this record's parent, if any. - public var parentRecordPrimaryKey: String? + import CloudKit - /// The type of this record's parent, _i.e._ its table name, if any. - public var parentRecordType: String? - - /// The name of this record's parent, if any. + /// A table that tracks metadata related to synchronized data. /// - /// This field encodes both the table name and primary key of the parent record in the format - /// "primaryKey:tableName", for example: + /// Each row of this table represents a synchronized record across all tables synchronized with + /// CloudKit. This means that the sum of the count of rows across all synchronized tables in your + /// application is the number of rows this one single table holds. However, this table is held + /// in a database separate from your app's database. /// - /// ```swift - /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" - /// ``` - // @Column(generated: .virtual) - public let parentRecordName: String? - - /// The last known `CKRecord` received from the server. /// - /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`. - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - public var lastKnownServerRecord: CKRecord? - - /// The last known `CKRecord` received from the server with all fields archived. - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - package var _lastKnownServerRecordAllFields: CKRecord? - - /// The `CKShare` associated with this record, if it is shared. - // @Column(as: CKShare?.SystemFieldsRepresentation.self) - public var share: CKShare? - - // @Column(generated: .virtual) - public let isShared: Bool - - /// The date the user last modified the record. - public var userModificationDate: Date - - package init( - recordPrimaryKey: String, - recordType: String, - parentRecordPrimaryKey: String? = nil, - parentRecordType: String? = nil, - lastKnownServerRecord: CKRecord? = nil, - _lastKnownServerRecordAllFields: CKRecord? = nil, - share: CKShare? = nil, - userModificationDate: Date - ) { - self.recordPrimaryKey = recordPrimaryKey - self.recordType = recordType - self.recordName = "\(recordPrimaryKey):\(recordType)" - self.parentRecordPrimaryKey = parentRecordPrimaryKey - self.parentRecordType = parentRecordType - if let parentRecordPrimaryKey, let parentRecordType { - self.parentRecordName = "\(parentRecordPrimaryKey):\(parentRecordType)" - } else { - self.parentRecordName = nil - } - self.lastKnownServerRecord = lastKnownServerRecord - self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields - self.share = share - self.isShared = share != nil - self.userModificationDate = userModificationDate - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") + public struct SyncMetadata: Hashable, Sendable { + /// The unique identifier of the record synchronized. + public var recordPrimaryKey: String + + /// The type of the record synchronized, _i.e._ its table name. + public var recordType: String + + /// The name of the record synchronized. + /// + /// This field encodes both the table name and primary key of the record synchronized in + /// the format "primaryKey:tableName", for example: + /// + /// ```swift + /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" + /// ``` + // @Column(generated: .virtual) + public let recordName: String + + /// The unique identifier of this record's parent, if any. + public var parentRecordPrimaryKey: String? + + /// The type of this record's parent, _i.e._ its table name, if any. + public var parentRecordType: String? + + /// The name of this record's parent, if any. + /// + /// This field encodes both the table name and primary key of the parent record in the format + /// "primaryKey:tableName", for example: + /// + /// ```swift + /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" + /// ``` + // @Column(generated: .virtual) + public let parentRecordName: String? + + /// The last known `CKRecord` received from the server. + /// + /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`. + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + public var lastKnownServerRecord: CKRecord? - // @Selection @Table - struct AncestorMetadata { - let recordName: String - let parentRecordName: String? + /// The last known `CKRecord` received from the server with all fields archived. // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - let lastKnownServerRecord: CKRecord? + package var _lastKnownServerRecordAllFields: CKRecord? + + /// The `CKShare` associated with this record, if it is shared. + // @Column(as: CKShare?.SystemFieldsRepresentation.self) + public var share: CKShare? + + // @Column(generated: .virtual) + public let isShared: Bool + + /// The date the user last modified the record. + public var userModificationDate: Date + + package init( + recordPrimaryKey: String, + recordType: String, + parentRecordPrimaryKey: String? = nil, + parentRecordType: String? = nil, + lastKnownServerRecord: CKRecord? = nil, + _lastKnownServerRecordAllFields: CKRecord? = nil, + share: CKShare? = nil, + userModificationDate: Date + ) { + self.recordPrimaryKey = recordPrimaryKey + self.recordType = recordType + self.recordName = "\(recordPrimaryKey):\(recordType)" + self.parentRecordPrimaryKey = parentRecordPrimaryKey + self.parentRecordType = parentRecordType + if let parentRecordPrimaryKey, let parentRecordType { + self.parentRecordName = "\(parentRecordPrimaryKey):\(parentRecordType)" + } else { + self.parentRecordName = nil + } + self.lastKnownServerRecord = lastKnownServerRecord + self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields + self.share = share + self.isShared = share != nil + self.userModificationDate = userModificationDate + } + + // @Selection @Table + struct AncestorMetadata { + let recordName: String + let parentRecordName: String? + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let lastKnownServerRecord: CKRecord? + } } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - package static func find( - _ primaryKey: T.PrimaryKey.QueryOutput, - table _: T.Type, - ) -> Where { - Self.where { - SQLQueryExpression( - """ - \($0.recordPrimaryKey) = \(T.PrimaryKey(queryOutput: primaryKey)) \ - AND \($0.recordType) = \(bind: T.tableName) - """ - ) + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { + package static func find( + _ primaryKey: T.PrimaryKey.QueryOutput, + table _: T.Type, + ) + -> Where + where T.PrimaryKey: IdentifierStringConvertible { + T.metadata(for: primaryKey) } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { - /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. - /// - /// - Parameter id: The ID of the record. - package static func recordName(for id: PrimaryKey.QueryOutput) -> String { - "\(id.rawIdentifier):\(tableName)" + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { + public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where { + SyncMetadata.where { + SQLQueryExpression( + """ + \($0.recordPrimaryKey) = \(PrimaryKey(queryOutput: primaryKey)) \ + AND \($0.recordType) = \(bind: tableName) + """ + ) + } + } } - var recordName: String { - Self.recordName(for: self[keyPath: Self.columns.primaryKey.keyPath]) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { + /// Constructs a ``SyncMetadata/RecordName-swift.struct`` for a primary keyed table give an ID. + /// + /// - Parameter id: The ID of the record. + package static func recordName(for id: PrimaryKey.QueryOutput) -> String { + "\(id.rawIdentifier):\(tableName)" + } + + var recordName: String { + Self.recordName(for: self[keyPath: Self.columns.primaryKey.keyPath]) + } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { - public var recordName: some QueryExpression { - _recordName + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { + public var recordName: some QueryExpression { + _recordName + } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTableDefinition { - var _recordName: some QueryExpression { - SQLQueryExpression("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTableDefinition { + var _recordName: some QueryExpression { + SQLQueryExpression("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") + } } -} #endif diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 13761b36..aaeb2f4d 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -500,21 +500,25 @@ exposed for you to query it in whichever way you want. to attach the metadatabase to your database connection. This can be done with the ``GRDB/Database/attachMetadatabase(containerIdentifier:)`` method defined on `Database`. +With that done you can use the ``StructuredQueriesCore/PrimaryKeyedTable/metadata(for:)`` method +to construct a SQL query for fetching the meta data associated with one of your records. + For example, if you want to retrieve the `CKRecord` that is associated with a particular row in one of your tables, say a reminder, then you can use ``SyncMetadata/lastKnownServerRecord`` to retreive the `CKRecord` and then invoke a CloudKit database function to retreive all of the details: ```swift -let metadata = try database.read { db in - try SyncMetadata - .find(RemindersList.recordName(for: remindersListID)) +let lastKnownServerRecord = try database.read { db in + try RemindersList + .metadata(for: remindersListID) + .select(\.lastKnownServerRecord) .fetchOne(db) } -guard let metadata +guard let lastKnownServerRecord else { return } let ckRecord = try await container.privateCloudDatabase - .record(for: metadata.lastKnownServerRecord.recordID) + .record(for: lastKnownServerRecord.recordID) ``` > Important: In the above snippet we are explicitly using `privateCloudDatabase`, but that is @@ -530,14 +534,13 @@ It is also possible to fetch the `CKShare` associated with a record if it has be will give you access to the most current list of paricipants and permissions for the shared record: ```swift -let metadata = try database.read { db in - try SyncMetadata - .find(RemindersList.recordName(for: remindersListID)) +let share = try database.read { db in + try RemindersList + .metadata(for: remindersListID) + .select(\.share) .fetchOne(db) } -guard - let metadata, - let share = metadata.share +guard let share else { return } let ckRecord = try await container.sharedCloudDatabase From 72797df6097ca7d6789abfa6e4d9fa02edfe7e41 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 30 Jul 2025 11:31:00 -0700 Subject: [PATCH 457/581] Apply suggestions from code review --- Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift index dfb34db2..4841380f 100644 --- a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift +++ b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift @@ -30,7 +30,7 @@ return Self.where { SQLQueryExpression( """ - (\($0.recordName), \($0.zoneName), \($0.ownerName)) + (\($0.recordName), \($0.zoneName), \($0.ownerName)) \ IN (\(condition)) """ ) From e37a14e66613dcf3d9277860f9238472577ac182 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 30 Jul 2025 11:58:30 -0700 Subject: [PATCH 458/581] wip --- .../xcshareddata/swiftpm/Package.resolved | 20 +------------ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 30 +++++++++++++++---- .../CloudKitTests/SyncEngineTests.swift | 8 +++++ 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 15cf694d..46621b31 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1a7bc0659d563286d3726b3ca6b02007396575ec65be2095b61b5cc9e3846e25", + "originHash" : "cfa986227a2051ca83eae9c181301c75e9fcd30d8f199a7ee1c7b269b548e192", "pins" : [ { "identity" : "combine-schedulers", @@ -73,24 +73,6 @@ "version" : "1.9.2" } }, - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-plugin", - "state" : { - "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", - "version" : "1.4.5" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 18557ef0..d95823e6 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -118,7 +118,7 @@ self.logger = logger self.metadatabase = try defaultMetadatabase( logger: logger, - url: URL.metadatabase( + url: try URL.metadatabase( databasePath: userDatabase.path, containerIdentifier: container.containerIdentifier ) @@ -1418,11 +1418,31 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension URL { - package static func metadatabase(databasePath: String, containerIdentifier: String?) -> URL { - URL(filePath: databasePath) - .deletingPathExtension() + package static func metadatabase( + databasePath: String, + containerIdentifier: String? + ) throws -> URL { + guard + let databaseURL = URL(string: databasePath), + !databaseURL.isInMemory + else { + struct InMemoryError: Error {} + throw InMemoryError() + } + return databaseURL + .deletingLastPathComponent() + .appending(component: ".\(databaseURL.deletingPathExtension().lastPathComponent)") .appendingPathExtension("metadata\(containerIdentifier.map { "-\($0)" } ?? "").sqlite") } + + package var isInMemory: Bool { + path.isEmpty + || path.hasPrefix(":memory:") + || URLComponents(url: self, resolvingAgainstBaseURL: false)? + .queryItems? + .contains(where: { $0.name == "mode" && $0.value == "memory" }) + == true + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -1488,7 +1508,7 @@ struct PathError: Error {} throw PathError() } - let url = URL.metadatabase( + let url = try URL.metadatabase( databasePath: databasePath, containerIdentifier: containerIdentifier ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index 0a3635fb..c1eba9ca 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -30,6 +30,14 @@ extension BaseCloudKitTests { ) } #endif + + @Test func inMemory() throws { + #expect(URL(string: "")?.isInMemory == nil) + #expect(URL(string: ":memory:")?.isInMemory == true) + #expect(URL(string: ":memory:?cache=shared")?.isInMemory == true) + #expect(URL(string: "file::memory:")?.isInMemory == true) + #expect(URL(string: "file:memdb1?mode=memory&cache=shared")?.isInMemory == true) + } } } From a077922e7aa313b5519eedc97615e6d8047e762e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 30 Jul 2025 14:56:37 -0500 Subject: [PATCH 459/581] wip; --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 14 +++++------ .../CloudKit/SyncMetadata.swift | 12 ---------- .../CloudKitTests/CloudKitTests.swift | 24 ++++++++----------- .../FetchRecordZoneChangesTests.swift | 10 ++++---- .../ForeignKeyConstraintTests.swift | 18 +++++++------- 5 files changed, 31 insertions(+), 47 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 700b8cdd..05d5b66b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -163,7 +163,7 @@ .execute(db) } db.add(function: .datetime) - db.add(function: .syncEngineIsUpdatingRecord) + db.add(function: .syncEngineIsSynchronizingChanges) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) @@ -344,7 +344,7 @@ } db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) - db.remove(function: .syncEngineIsUpdatingRecord) + db.remove(function: .syncEngineIsSynchronizingChanges) db.remove(function: .datetime) } try await userDatabase.write { db in @@ -430,7 +430,7 @@ } public static func isSynchronizingChanges() -> SQLQueryExpression { - SQLQueryExpression("\(raw: DatabaseFunction.syncEngineIsUpdatingRecord.name)()") + SQLQueryExpression("\(raw: DatabaseFunction.syncEngineIsSynchronizingChanges.name)()") } } @@ -1379,8 +1379,8 @@ } } - fileprivate static var syncEngineIsUpdatingRecord: Self { - Self(.sqliteDataCloudKitSchemaName + "_" + "syncEngineIsUpdatingRecord", argumentCount: 0) { + fileprivate static var syncEngineIsSynchronizingChanges: Self { + Self(.sqliteDataCloudKitSchemaName + "_" + "syncEngineIsSynchronizingChanges", argumentCount: 0) { _ in SyncEngine._isSynchronizingChanges } @@ -1533,7 +1533,7 @@ let isValid = sql .lowercased() - .contains("\(DatabaseFunction.syncEngineIsUpdatingRecord.name)()".lowercased()) + .contains("\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()".lowercased()) return isValid ? nil : name } guard invalidTriggers.isEmpty @@ -1609,7 +1609,7 @@ let triggers: [String] public var localizedDescription: String { """ - Triggers must include '\(DatabaseFunction.syncEngineIsUpdatingRecord.name)()' check: \ + Triggers must include '\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()' check: \ \(triggers.map { "'\($0)'" }.joined(separator: ", ")) """ } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index d8fe250a..ad0d7c82 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -102,18 +102,6 @@ } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncMetadata { - package static func find( - _ primaryKey: T.PrimaryKey.QueryOutput, - table _: T.Type, - ) - -> Where - where T.PrimaryKey: IdentifierStringConvertible { - T.metadata(for: primaryKey) - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where { diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index b8b285ff..4fec6e12 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -540,7 +540,7 @@ extension BaseCloudKitTests { let metadata = try await userDatabase.userRead { db in - try SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) + try RemindersList.metadata(for: 1).fetchOne(db) } #expect(metadata != nil) } @@ -552,6 +552,7 @@ extension BaseCloudKitTests { SELECT name FROM pragma_function_list WHERE name LIKE \(bind: String.sqliteDataCloudKitSchemaName + "_%") + ORDER BY name """, as: String.self ) @@ -561,10 +562,10 @@ extension BaseCloudKitTests { ) { """ [ - [0]: "sqlitedata_icloud_syncengineisupdatingrecord", - [1]: "sqlitedata_icloud_datetime", + [0]: "sqlitedata_icloud_datetime", + [1]: "sqlitedata_icloud_diddelete", [2]: "sqlitedata_icloud_didupdate", - [3]: "sqlitedata_icloud_diddelete" + [3]: "sqlitedata_icloud_syncengineissynchronizingchanges" ] """ } @@ -709,8 +710,7 @@ extension BaseCloudKitTests { let userModificationDate = try #require( try await userDatabase.userRead { db in - try SyncMetadata - .find(1, table: RemindersList.self) + try RemindersList.metadata(for: 1) .select(\.userModificationDate) .fetchOne(db) ?? nil } @@ -730,8 +730,7 @@ extension BaseCloudKitTests { let metadata = try #require( try await userDatabase.userRead { db in - try SyncMetadata - .find(1, table: RemindersList.self) + try RemindersList.metadata(for: 1) .fetchOne(db) } ) @@ -840,8 +839,7 @@ extension BaseCloudKitTests { let userModificationDate = try #require( try await userDatabase.userRead { db in - try SyncMetadata - .find(1, table: RemindersList.self) + try RemindersList.metadata(for: 1) .select(\.userModificationDate) .fetchOne(db) ?? nil } @@ -860,8 +858,7 @@ extension BaseCloudKitTests { let metadata = try #require( try await userDatabase.userRead { db in - try SyncMetadata - .find(1, table: RemindersList.self) + try RemindersList.metadata(for: 1) .fetchOne(db) } ) @@ -932,8 +929,7 @@ extension BaseCloudKitTests { } == [] ) let metadata = try await userDatabase.userRead { db in - try SyncMetadata - .find(1, table: RemindersList.self) + try RemindersList.metadata(for: 1) .fetchOne(db) } #expect(metadata == nil) diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index ad185004..7d65a570 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -150,7 +150,7 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + try Reminder.metadata(for: 1).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) let reminder = try #require(try Reminder.find(1).fetchOne(db)) @@ -185,7 +185,7 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + try Reminder.metadata(for: 1).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) let reminder = try #require(try Reminder.find(1).fetchOne(db)) @@ -237,7 +237,7 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) + try RemindersList.metadata(for: 1).fetchOne(db) ) #expect(metadata.recordName == RemindersList.recordName(for: 1)) let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) @@ -347,13 +347,13 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let reminderMetadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + 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 SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) + try RemindersList.metadata(for: 1).fetchOne(db) ) #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) #expect(remindersListMetadata.parentRecordName == nil) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index 50160dae..fa5ff547 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -79,13 +79,13 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let reminderMetadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + 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 SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) + try RemindersList.metadata(for: 1).fetchOne(db) ) #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) #expect(remindersListMetadata.parentRecordName == nil) @@ -370,13 +370,13 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let reminderMetadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + 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 SyncMetadata.find(1, table: RemindersList.self).fetchOne(db) + try RemindersList.metadata(for: 1).fetchOne(db) ) #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) #expect(remindersListMetadata.parentRecordName == nil) @@ -573,7 +573,7 @@ extension BaseCloudKitTests { #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 2)) let reminderMetadata = try #require( - try SyncMetadata.find(1, table: Reminder.self) + try Reminder.metadata(for: 1) .fetchOne(db) ) #expect(reminderMetadata.parentRecordName == "2:remindersLists") @@ -621,7 +621,7 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + try Reminder.metadata(for: 1).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) let reminder = try #require(try Reminder.find(1).fetchOne(db)) @@ -652,7 +652,7 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + try Reminder.metadata(for: 1).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) let reminder = try #require(try Reminder.find(1).fetchOne(db)) @@ -699,7 +699,7 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + try Reminder.metadata(for: 1).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) let reminder = try #require(try Reminder.find(1).fetchOne(db)) @@ -752,7 +752,7 @@ extension BaseCloudKitTests { try await userDatabase.read { db in let metadata = try #require( - try SyncMetadata.find(1, table: Reminder.self).fetchOne(db) + try Reminder.metadata(for: 1).fetchOne(db) ) #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) let reminder = try #require(try Reminder.find(1).fetchOne(db)) From f269f87427552160694688e539dcc2e30c920f77 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:01:00 -0500 Subject: [PATCH 460/581] Update Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift Co-authored-by: Stephen Celis --- Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index dfeaefcd..a2a52c9f 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -175,5 +175,5 @@ private let previousUserRecordID = CKRecord.ID( recordName: "previousUser" ) private let currentUserRecordID = CKRecord.ID( - recordName: "previousUser" + recordName: "currentUser" ) From d781ff77a97f004723b49a3391cd2a9ab5447b07 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:01:30 -0500 Subject: [PATCH 461/581] Update Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift Co-authored-by: Stephen Celis --- .../SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift index 885b1e0e..4049d554 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift @@ -9,7 +9,7 @@ import Testing extension BaseCloudKitTests { @MainActor - final class AccountLifecycleTests: BaseCloudKitTests, @unchecked Sendable { + final class AccountLifecycleTests: BaseCloudKitTests, Sendable { @Test func signOutClearsUserDatabaseAndMetadatabase() async throws { try await userDatabase.userWrite { db in try db.seed { From cd374bd8c80e111ba6d9594d2471eefee6352e62 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 30 Jul 2025 15:19:03 -0700 Subject: [PATCH 462/581] wip --- .../CloudKit/CloudKitSharing.swift | 55 +++++++++++++++---- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 6 ++ .../CloudKitTests/SharingTests.swift | 8 +-- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 2726e9e9..9f4dbe26 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -25,24 +25,52 @@ public struct SharedRecord: Hashable, Identifiable, Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { - // TODO: Move errors into single 'SyncEngine.Error' type? - public struct UnrecognizedTable: Error {} - public struct RecordMustBeRoot: Error {} - public struct NoCKRecordFound: Error {} - public struct PrivateRootRecord: Error {} + private struct SharingError: LocalizedError { + enum Reason { + case recordMetadataNotFound + case recordNotRoot + case recordTableNotSynchronized + case recordTablePrivate + } + + let recordTableName: String + let recordPrimaryKey: String + let reason: Reason + + var localizedDescription: String { + "The record could not be shared." + } + } public func share( record: T, configure: @Sendable (CKShare) -> Void ) async throws -> SharedRecord where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { - guard !privateTables.contains(where: { T.self == $0 }) - else { throw PrivateRootRecord() } guard tablesByName[T.tableName] != nil - else { throw UnrecognizedTable() } + else { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordTableNotSynchronized + ) + } guard foreignKeysByTableName[T.tableName]?.isEmpty ?? true - else { throw RecordMustBeRoot() } - + else { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordNotRoot + ) + } + guard !privateTables.contains(where: { T.self == $0 }) + else { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordTablePrivate + ) + } let recordName = record.recordName let metadata = try await metadatabase.read { db in @@ -50,10 +78,13 @@ extension SyncEngine { .where { $0.recordName.eq(recordName) } .fetchOne(db) } ?? nil - guard let metadata else { - throw NoCKRecordFound() + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordMetadataNotFound + ) } let rootRecord = diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 645ee773..d39cfdb3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1582,6 +1582,7 @@ } } + // TODO: Private, opaque error @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public struct InvalidTableName: LocalizedError { let tableName: String @@ -1592,6 +1593,7 @@ } } + // TODO: Private, opaque error @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public struct InvalidParentForeignKey: LocalizedError { let tableName: String @@ -1604,6 +1606,7 @@ } } + // TODO: Private, opaque error @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public struct InvalidUserTriggers: LocalizedError { let triggers: [String] @@ -1615,6 +1618,7 @@ } } + // TODO: Private, opaque error public struct UniqueConstraintDisallowed: Error { let localizedDescription: String init(table: any PrimaryKeyedTable.Type, columns: [String]) { @@ -1624,6 +1628,8 @@ """ } } + + // TODO: Private, opaque error public struct NonNullColumnMustHaveDefault: Error { let localizedDescription: String init(table: any PrimaryKeyedTable.Type, columns: [String]) { diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 320d49ec..8b74aa32 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -22,14 +22,14 @@ extension BaseCloudKitTests { await syncEngine.processPendingRecordZoneChanges(scope: .private) - await #expect(throws: SyncEngine.RecordMustBeRoot.self) { + await #expect(throws: (any Error).self) { _ = try await self.syncEngine.share(record: reminder, configure: { _ in }) } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareUnrecognizedTable() async throws { - await #expect(throws: SyncEngine.UnrecognizedTable.self) { + await #expect(throws: (any Error).self) { _ = try await self.syncEngine.share( record: UnsyncedModel(id: 42), configure: { _ in } @@ -39,7 +39,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func sharePrivateTable() async throws { - await #expect(throws: SyncEngine.PrivateRootRecord.self) { + await #expect(throws: (any Error).self) { _ = try await self.syncEngine.share( record: RemindersListPrivate(id: 1, remindersListID: 1), configure: { _ in } @@ -49,7 +49,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareRecordBeforeSync() async throws { - await #expect(throws: SyncEngine.NoCKRecordFound.self) { + await #expect(throws: (any Error).self) { _ = try await self.syncEngine.share( record: RemindersList(id: 1), configure: { _ in } From e120165551feb435345c1f5cf747915b79a47d42 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 30 Jul 2025 17:28:57 -0500 Subject: [PATCH 463/581] fix --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index af3e02f8..d60e507f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -256,7 +256,7 @@ } try await userDatabase.write { db in - try Self.$_isUpdatingRecord.withValue(false) { + try Self.$_isSynchronizingChanges.withValue(false) { for tableName in newTableNames { try self.uploadRecordsToCloudKit(tableName: tableName, db: db) } From a518efef149fd8e124562dde94c80377be8a59a4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 30 Jul 2025 17:39:41 -0500 Subject: [PATCH 464/581] fix test --- .../CloudKitTests/SyncEngineValidationTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index c8c54cd2..f0679e9a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -141,7 +141,9 @@ extension BaseCloudKitTests { } @Test func doNotValidateTriggersOnNonSyncedTables() async throws { - let database = try DatabaseQueue() + let database = try DatabaseQueue( + path: URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite").path() + ) try await database.write { db in try #sql( """ From f1dca5126c5819bfd396dfdce057f6487f3cd75b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 31 Jul 2025 14:25:21 -0700 Subject: [PATCH 465/581] wip --- .../CloudKit/CloudKitSharing.swift | 29 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 249 ++++++++++-------- .../CloudKitTests/SharingTests.swift | 90 ++++++- .../SyncEngineValidationTests.swift | 78 ++++-- 4 files changed, 298 insertions(+), 148 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 9f4dbe26..5a240982 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -28,7 +28,7 @@ extension SyncEngine { private struct SharingError: LocalizedError { enum Reason { case recordMetadataNotFound - case recordNotRoot + case recordNotRoot([ForeignKey]) case recordTableNotSynchronized case recordTablePrivate } @@ -36,8 +36,9 @@ extension SyncEngine { let recordTableName: String let recordPrimaryKey: String let reason: Reason + let debugDescription: String - var localizedDescription: String { + var errorDescription: String? { "The record could not be shared." } } @@ -52,15 +53,20 @@ extension SyncEngine { throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, - reason: .recordTableNotSynchronized + reason: .recordTableNotSynchronized, + debugDescription: """ + Table is not shareable: table type not passed to 'tables' parameter of 'SyncEngine.init'. + """ ) } - guard foreignKeysByTableName[T.tableName]?.isEmpty ?? true - else { + if let foreignKeys = foreignKeysByTableName[T.tableName], !foreignKeys.isEmpty { throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, - reason: .recordNotRoot + reason: .recordNotRoot(foreignKeys), + debugDescription: """ + Only root records are shareable, but parent record(s) detected via foreign key(s). + """ ) } guard !privateTables.contains(where: { T.self == $0 }) @@ -68,7 +74,11 @@ extension SyncEngine { throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, - reason: .recordTablePrivate + reason: .recordTablePrivate, + debugDescription: """ + Private tables are not shareable: table type passed to 'privateTables' parameter of \ + 'SyncEngine.init'. + """ ) } let recordName = record.recordName @@ -83,7 +93,10 @@ extension SyncEngine { throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, - reason: .recordMetadataNotFound + reason: .recordMetadataNotFound, + debugDescription: """ + No sync metadata found for record. Has the record been saved to the database? + """ ) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index cc3813df..15a365f7 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -148,17 +148,33 @@ metadatabase: any DatabaseReader ) throws -> Task? { try userDatabase.write { db in - let hasAttachedMetadatabase: Bool = + let attachedMetadatabasePath: String? = try SQLQueryExpression( """ - SELECT count(*) - FROM pragma_database_list + SELECT "file" + FROM pragma_database_list() WHERE "name" = \(bind: String.sqliteDataCloudKitSchemaName) """, - as: Int.self + as: String.self ) - .fetchOne(db) == 1 - if !hasAttachedMetadatabase { + .fetchOne(db) + if let attachedMetadatabasePath { + let attachedMetadatabaseName = URL(filePath: metadatabase.path).lastPathComponent + let metadatabaseName = URL(filePath: attachedMetadatabasePath).lastPathComponent + if attachedMetadatabaseName != metadatabaseName { + throw SchemaError( + reason: .metadatabaseMismatch( + attachedPath: attachedMetadatabasePath, + syncEngineConfiguredPath: metadatabase.path + ), + debugDescription: """ + Metadatabase attached in 'prepareDatabase' does not match metadatabase prepared in \ + 'SyncEngine.init'. Are the CloudKit container identifiers different? + """ + ) + } + + } else { try SQLQueryExpression( """ ATTACH DATABASE \(bind: metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) @@ -1401,8 +1417,12 @@ let databaseURL = URL(string: databasePath), !databaseURL.isInMemory else { - struct InMemoryError: Error {} - throw InMemoryError() + throw SyncEngine.SchemaError( + reason: .inMemoryDatabase, + debugDescription: """ + Can't synchronize temporary/in-memory database: it must be written to the file system. + """ + ) } return databaseURL .deletingLastPathComponent() @@ -1481,7 +1501,12 @@ .fetchOne(self) guard let databasePath else { struct PathError: Error {} - throw PathError() + throw SyncEngine.SchemaError( + reason: .unknown, + debugDescription: """ + Expected to load a database path from the connection, but failed to do so. + """ + ) } let url = try URL.metadatabase( databasePath: databasePath, @@ -1504,6 +1529,49 @@ } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + struct SchemaError: LocalizedError { + enum Reason { + case inMemoryDatabase + case invalidForeignKeyAction(ForeignKey) + case invalidTableName(String) + case metadatabaseMismatch(attachedPath: String, syncEngineConfiguredPath: String) + case nonNullColumnsWithoutDefault(tableName: String, columnNames: [String]) + case triggersWithoutSynchronizationCheck([String]) + case unknown + } + let reason: Reason + let debugDescription: String + + var errorDescription: String? { + "Could not synchronize data with iCloud." + } + } + } + + // TODO: Private, opaque error + // public struct UniqueConstraintDisallowed: Error { + // let localizedDescription: String + // init(table: any PrimaryKeyedTable.Type, columns: [String]) { + // localizedDescription = """ + // Table '\(table.tableName)' has column\(columns.count == 1 ? "" : "s") with unique \ + // constraints: \(columns.map { "'\($0)'" }.joined(separator: ", ")) + // """ + // } + // } + + // TODO: Private, opaque error + // public struct NonNullColumnMustHaveDefault: Error { + // let localizedDescription: String + // init(table: any PrimaryKeyedTable.Type, columns: [String]) { + // localizedDescription = """ + // Table '\(table.tableName)' has non-null column\(columns.count == 1 ? "" : "s") with no \ + // default: \(columns.map { "'\($0)'" }.joined(separator: ", ")) + // """ + // } + // } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func validateSchema( tables: [any PrimaryKeyedTable.Type], @@ -1513,135 +1581,92 @@ let tableNames = Set(tables.map { $0.tableName }) for tableName in tableNames { if tableName.contains(":") { - throw InvalidTableName(tableName: tableName) + throw SyncEngine.SchemaError( + reason: .invalidTableName(tableName), + debugDescription: "Table name contains invalid character ':'" + ) } } try userDatabase.read { db in let triggers = try SQLQueryExpression( - """ - SELECT "name", "tbl_name", "sql" - FROM "sqlite_master" - WHERE "type" = 'trigger' - UNION - SELECT "name", "tbl_name", "sql" - FROM "sqlite_temp_master" - WHERE "type" = 'trigger' - """, - as: (String, String, String).self + """ + SELECT "name", "tbl_name", "sql" + FROM "sqlite_master" + WHERE "type" = 'trigger' + UNION + SELECT "name", "tbl_name", "sql" + FROM "sqlite_temp_master" + WHERE "type" = 'trigger' + """, + as: (String, String, String).self ) - .fetchAll(db) - .filter { _, tableName, _ in tableNames.contains(tableName) } + .fetchAll(db) + .filter { _, tableName, _ in tableNames.contains(tableName) } let invalidTriggers = triggers.compactMap { name, _, sql in let isValid = - sql + sql .lowercased() .contains("\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()".lowercased()) return isValid ? nil : name } guard invalidTriggers.isEmpty else { - throw InvalidUserTriggers(triggers: invalidTriggers) + throw SyncEngine.SchemaError( + reason: .triggersWithoutSynchronizationCheck(invalidTriggers), + debugDescription: """ + Triggers must include '\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()' \ + check: \(triggers.map { "'\($0)'" }.joined(separator: ", ")). + """ + ) } for (tableName, foreignKeys) in foreignKeysByTableName { if foreignKeys.count == 1, - let foreignKey = foreignKeys.first, - [.restrict, .noAction].contains(foreignKey.onDelete) + let foreignKey = foreignKeys.first, + [.restrict, .noAction].contains(foreignKey.onDelete) { - throw InvalidParentForeignKey(tableName: tableName, foreignKey: foreignKey) + throw SyncEngine.SchemaError( + reason: .invalidForeignKeyAction(foreignKey), + debugDescription: """ + Foreign key \(tableName.debugDescription).\(foreignKey.from.debugDescription) action \ + not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'. + """ + ) } } for table in tables { - // // TODO: write tests for this - // let columnsWithUniqueConstraints = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) - // WHERE "unique" = 1 AND "origin" <> 'pk' - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !columnsWithUniqueConstraints.isEmpty { - // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) - // } - - // // TODO: write tests for this - // let nonNullColumnsWithNoDefault = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) - // WHERE "notnull" = 1 AND "dflt_value" IS NULL - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !nonNullColumnsWithNoDefault.isEmpty { - // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) - // } + // // TODO: write tests for this + // let columnsWithUniqueConstraints = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + // WHERE "unique" = 1 AND "origin" <> 'pk' + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !columnsWithUniqueConstraints.isEmpty { + // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) + // } + + // // TODO: write tests for this + // let nonNullColumnsWithNoDefault = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) + // WHERE "notnull" = 1 AND "dflt_value" IS NULL + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !nonNullColumnsWithNoDefault.isEmpty { + // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) + // } } } } - // TODO: Private, opaque error - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public struct InvalidTableName: LocalizedError { - let tableName: String - public var localizedDescription: String { - """ - Table name \(tableName.debugDescription) contains invalid character ':'. - """ - } - } - - // TODO: Private, opaque error - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public struct InvalidParentForeignKey: LocalizedError { - let tableName: String - let foreignKey: ForeignKey - public var localizedDescription: String { - """ - Foreign key \(tableName.debugDescription).\(foreignKey.from.debugDescription) action not \ - supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'. - """ - } - } - - // TODO: Private, opaque error - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public struct InvalidUserTriggers: LocalizedError { - let triggers: [String] - public var localizedDescription: String { - """ - Triggers must include '\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()' check: \ - \(triggers.map { "'\($0)'" }.joined(separator: ", ")) - """ - } - } - - // TODO: Private, opaque error - public struct UniqueConstraintDisallowed: Error { - let localizedDescription: String - init(table: any PrimaryKeyedTable.Type, columns: [String]) { - localizedDescription = """ - Table '\(table.tableName)' has column\(columns.count == 1 ? "" : "s") with unique \ - constraints: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - """ - } - } - - // TODO: Private, opaque error - public struct NonNullColumnMustHaveDefault: Error { - let localizedDescription: String - init(table: any PrimaryKeyedTable.Type, columns: [String]) { - localizedDescription = """ - Table '\(table.tableName)' has non-null column\(columns.count == 1 ? "" : "s") with no \ - default: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - """ - } - } - private struct HashablePrimaryKeyedTableType: Hashable { let type: any PrimaryKeyedTable.Type init(_ type: any PrimaryKeyedTable.Type) { diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 29c05d4e..6a0010d3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -22,39 +22,121 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await #expect(throws: (any Error).self) { + let error = await #expect(throws: (any Error).self) { _ = try await self.syncEngine.share(record: reminder, configure: { _ in }) } + assertInlineSnapshot(of: error?.localizedDescription, as: .customDump) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SharingError( + recordTableName: "reminders", + recordPrimaryKey: "1", + reason: .recordNotRoot( + [ + [0]: ForeignKey( + table: "remindersLists", + from: "remindersListID", + to: "id", + onUpdate: .cascade, + onDelete: .cascade, + notnull: true + ) + ] + ), + debugDescription: "Only root records are shareable, but parent record(s) detected via foreign key(s)." + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareUnrecognizedTable() async throws { - await #expect(throws: (any Error).self) { + let error = await #expect(throws: (any Error).self) { _ = try await self.syncEngine.share( record: UnsyncedModel(id: 42), configure: { _ in } ) } + assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + #""" + SyncEngine.SharingError( + recordTableName: "unsyncedModels", + recordPrimaryKey: "42", + reason: .recordTableNotSynchronized, + debugDescription: "Table is not shareable: table type not passed to \'tables\' parameter of \'SyncEngine.init\'." + ) + """# + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func sharePrivateTable() async throws { - await #expect(throws: (any Error).self) { + let error = await #expect(throws: (any Error).self) { _ = try await self.syncEngine.share( record: RemindersListPrivate(id: 1, remindersListID: 1), configure: { _ in } ) } + assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SharingError( + recordTableName: "remindersListPrivates", + recordPrimaryKey: "1", + reason: .recordNotRoot( + [ + [0]: ForeignKey( + table: "remindersLists", + from: "remindersListID", + to: "id", + onUpdate: .noAction, + onDelete: .cascade, + notnull: true + ) + ] + ), + debugDescription: "Only root records are shareable, but parent record(s) detected via foreign key(s)." + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func shareRecordBeforeSync() async throws { - await #expect(throws: (any Error).self) { + let error = await #expect(throws: (any Error).self) { _ = try await self.syncEngine.share( record: RemindersList(id: 1), configure: { _ in } ) } + assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SharingError( + recordTableName: "remindersLists", + recordPrimaryKey: "1", + reason: .recordMetadataNotFound, + debugDescription: "No sync metadata found for record. Has the record been saved to the database?" + ) + """ + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index f0679e9a..7a48a7f1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -16,7 +16,7 @@ extension BaseCloudKitTests { struct SyncEngineValidationTests { @Test func tableNameValidation() async throws { let error = try #require( - await #expect(throws: InvalidTableName.self) { + await #expect(throws: (any Error).self) { let database = try DatabaseQueue() _ = try await SyncEngine( container: MockCloudContainer( @@ -29,18 +29,24 @@ extension BaseCloudKitTests { ) } ) - #expect( - error.localizedDescription.hasPrefix( - """ - Table name "invalid:table" contains invalid character ':'. - """ + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + #""" + SyncEngine.SchemaError( + reason: .invalidTableName("invalid:table"), + debugDescription: "Table name contains invalid character \':\'" ) - ) + """# + } } @Test func foreignKeyActionValidation() async throws { let error = try #require( - await #expect(throws: InvalidParentForeignKey.self) { + await #expect(throws: (any Error).self) { var configuration = Configuration() configuration.foreignKeysEnabled = false let database = try DatabaseQueue(configuration: configuration) @@ -74,18 +80,33 @@ extension BaseCloudKitTests { ) } ) - #expect( - error.localizedDescription == - """ - Foreign key "children"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' \ - or 'SET NULL'. - """ - ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .invalidForeignKeyAction( + ForeignKey( + table: "parents", + from: "parentID", + to: "id", + onUpdate: .noAction, + onDelete: .noAction, + notnull: false + ) + ), + debugDescription: #"Foreign key "children"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# + ) + """ + } } @Test func userTriggerValidation() async throws { let error = try await #require( - #expect(throws: InvalidUserTriggers.self) { + #expect(throws: (any Error).self) { let database = try DatabaseQueue() try await database.write { db in try #sql( @@ -129,15 +150,24 @@ extension BaseCloudKitTests { ) } ) - - #expect( - error.localizedDescription.hasPrefix( - """ - Triggers must include 'sqlitedata_icloud_syncEngineIsSynchronizingChanges()' check: \ - 'non_temporary_trigger', 'temporary_trigger' - """ + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + #""" + SyncEngine.SchemaError( + reason: .triggersWithoutSynchronizationCheck( + [ + [0]: "non_temporary_trigger", + [1]: "temporary_trigger" + ] + ), + debugDescription: #"Triggers must include 'sqlitedata_icloud_syncEngineIsSynchronizingChanges()' check: '("non_temporary_trigger", "remindersLists", "CREATE TRIGGER \"non_temporary_trigger\"\nAFTER UPDATE ON \"remindersLists\"\nFOR EACH ROW BEGIN\n SELECT 1;\nEND")', '("temporary_trigger", "remindersLists", "CREATE TRIGGER \"temporary_trigger\"\nAFTER UPDATE ON \"remindersLists\"\nFOR EACH ROW BEGIN\n SELECT 1;\nEND")'."# ) - ) + """# + } } @Test func doNotValidateTriggersOnNonSyncedTables() async throws { From 908fe9a391ad7ba80f2e963d1201dc336a8d95ca Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 31 Jul 2025 14:34:41 -0700 Subject: [PATCH 466/581] wip --- .../CloudKitTests/SyncEngineTests.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index c1eba9ca..35e320b2 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -38,6 +38,39 @@ extension BaseCloudKitTests { #expect(URL(string: "file::memory:")?.isInMemory == true) #expect(URL(string: "file:memdb1?mode=memory&cache=shared")?.isInMemory == true) } + + @Test func metadatabaseMismatch() async throws { + let error = await #expect(throws: (any Error).self) { + var configuration = Configuration() + configuration.prepareDatabase { db in + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree") + } + let database = try DatabasePool( + path: NSTemporaryDirectory() + UUID().uuidString, + configuration: configuration + ) + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "iCloud.co.point-free", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [] + ) + } + assertInlineSnapshot(of: error, as: .customDump) { + #""" + SyncEngine.SchemaError( + reason: .metadatabaseMismatch( + attachedPath: "/private/var/folders/vj/bzr5j4ld7cz6jgpphc5kbs8m0000gn/T/.C1938F73-8A6E-40BA-BCF5-A10C07CA1EB6.metadata-iCloud.co.pointfree.sqlite", + syncEngineConfiguredPath: "/var/folders/vj/bzr5j4ld7cz6jgpphc5kbs8m0000gn/T/.C1938F73-8A6E-40BA-BCF5-A10C07CA1EB6.metadata-iCloud.co.point-free.sqlite" + ), + debugDescription: "Metadatabase attached in \'prepareDatabase\' does not match metadatabase prepared in \'SyncEngine.init\'. Are the CloudKit container identifiers different?" + ) + """# + } + } } } From 565220bd4e5155ea4878b0a8a017f86e2f7252ff Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 31 Jul 2025 14:35:40 -0700 Subject: [PATCH 467/581] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 15a365f7..982b154c 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -734,20 +734,21 @@ deletions: [(zoneID: CKRecordZone.ID, reason: CKDatabase.DatabaseChange.Deletion.Reason)], syncEngine: any SyncEngineProtocol ) async { - let defaultZoneDeleted = await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - var defaultZoneDeleted = false - for (zoneID, reason) in deletions { - guard zoneID == self.defaultZone.zoneID - else { continue } - switch reason { - case .deleted, .purged: - try deleteRecords(in: zoneID, db: db) - defaultZoneDeleted = true - case .encryptedDataReset: - try uploadRecords(in: zoneID, db: db) - @unknown default: - reportIssue("Unknown deletion reason: \(reason)") + let defaultZoneDeleted = + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + var defaultZoneDeleted = false + for (zoneID, reason) in deletions { + guard zoneID == self.defaultZone.zoneID + else { continue } + switch reason { + case .deleted, .purged: + try deleteRecords(in: zoneID, db: db) + defaultZoneDeleted = true + case .encryptedDataReset: + try uploadRecords(in: zoneID, db: db) + @unknown default: + reportIssue("Unknown deletion reason: \(reason)") } } return defaultZoneDeleted @@ -818,7 +819,7 @@ }, by: \.recordType ) - .mapValues { $0.map(\.recordID) } + .mapValues { $0.map(\.recordID) } for (recordType, recordIDs) in recordIDsByRecordType { let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) if let table = tablesByName[recordType] { @@ -1375,7 +1376,10 @@ } fileprivate static var syncEngineIsSynchronizingChanges: Self { - Self(.sqliteDataCloudKitSchemaName + "_" + "syncEngineIsSynchronizingChanges", argumentCount: 0) { + Self( + .sqliteDataCloudKitSchemaName + "_" + "syncEngineIsSynchronizingChanges", + argumentCount: 0 + ) { _ in SyncEngine._isSynchronizingChanges } @@ -1424,7 +1428,8 @@ """ ) } - return databaseURL + return + databaseURL .deletingLastPathComponent() .appending(component: ".\(databaseURL.deletingPathExtension().lastPathComponent)") .appendingPathExtension("metadata\(containerIdentifier.map { "-\($0)" } ?? "").sqlite") @@ -1589,22 +1594,22 @@ } try userDatabase.read { db in let triggers = try SQLQueryExpression( - """ - SELECT "name", "tbl_name", "sql" - FROM "sqlite_master" - WHERE "type" = 'trigger' - UNION - SELECT "name", "tbl_name", "sql" - FROM "sqlite_temp_master" - WHERE "type" = 'trigger' - """, - as: (String, String, String).self + """ + SELECT "name", "tbl_name", "sql" + FROM "sqlite_master" + WHERE "type" = 'trigger' + UNION + SELECT "name", "tbl_name", "sql" + FROM "sqlite_temp_master" + WHERE "type" = 'trigger' + """, + as: (String, String, String).self ) - .fetchAll(db) - .filter { _, tableName, _ in tableNames.contains(tableName) } + .fetchAll(db) + .filter { _, tableName, _ in tableNames.contains(tableName) } let invalidTriggers = triggers.compactMap { name, _, sql in let isValid = - sql + sql .lowercased() .contains("\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()".lowercased()) return isValid ? nil : name @@ -1622,8 +1627,8 @@ for (tableName, foreignKeys) in foreignKeysByTableName { if foreignKeys.count == 1, - let foreignKey = foreignKeys.first, - [.restrict, .noAction].contains(foreignKey.onDelete) + let foreignKey = foreignKeys.first, + [.restrict, .noAction].contains(foreignKey.onDelete) { throw SyncEngine.SchemaError( reason: .invalidForeignKeyAction(foreignKey), From 551efae0bbbfc5c28455a0c35b24d9997f5334d6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 1 Aug 2025 09:23:52 -0500 Subject: [PATCH 468/581] todos --- Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 2726e9e9..cf93f02b 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -58,15 +58,13 @@ extension SyncEngine { let rootRecord = metadata.lastKnownServerRecord - // 1) create record - // 2) (before sync) you share - // 3) create a CKRecord down below - // 4) a moment later, sync engine creates a record ?? CKRecord( recordType: metadata.recordType, recordID: CKRecord.ID(recordName: metadata.recordName, zoneID: defaultZone.zoneID) ) + // TODO: Catch unknownItem error from `.record(for:)` and go through `else` branch, otherwise rethrow + let sharedRecord: CKShare if let shareRecordID = rootRecord.share?.recordID, let existingShare = try await container.database(for: rootRecord.recordID) From a46c74f6798fe6aecc073f49c07e9929da5d558b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 1 Aug 2025 12:15:52 -0500 Subject: [PATCH 469/581] Support in-memory metadatabase. --- Examples/Reminders/Schema.swift | 5 ++- .../CloudKit/Metadatabase.swift | 23 ++++++++-- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 12 +++--- .../CloudKitTests/SyncEngineTests.swift | 43 +++++++++---------- 4 files changed, 50 insertions(+), 33 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 1266c9de..d3b7e803 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -119,7 +119,10 @@ func appDatabase() throws -> any DatabaseWriter { #endif } if context == .preview { - database = try DatabaseQueue(configuration: configuration) + database = try DatabaseQueue( + path: URL.documentsDirectory.appending(component: "db.sqlite").path(), + configuration: configuration + ) } else { let path = context == .live diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index e04f8b94..cb8cad7d 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -23,10 +23,25 @@ func defaultMetadatabase( at: .applicationSupportDirectory, withIntermediateDirectories: true ) - let metadatabase = try DatabasePool( - path: url.path(percentEncoded: false), - configuration: configuration - ) + + @Dependency(\.context) var context + guard !url.isInMemory || context != .live + else { + struct InMemoryDatabase: Error {} + throw InMemoryDatabase() + } + + let metadatabase: any DatabaseWriter = if url.isInMemory { + try DatabaseQueue( + path: url.absoluteString, + configuration: configuration + ) + } else { + try DatabasePool( + path: url.path(percentEncoded: false), + configuration: configuration + ) + } // TODO: go towards idempotent migrations instead of GRDB migrator by the end of all of this var migrator = DatabaseMigrator() // TODO: do we want this? diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d60e507f..c56c6528 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1397,12 +1397,14 @@ databasePath: String, containerIdentifier: String? ) throws -> URL { - guard - let databaseURL = URL(string: databasePath), - !databaseURL.isInMemory + guard let databaseURL = URL(string: databasePath) + else { + struct InvalidDatabsePath: Error {} + throw InvalidDatabsePath() + } + guard !databaseURL.isInMemory else { - struct InMemoryError: Error {} - throw InMemoryError() + return URL(string: "file:\(String.sqliteDataCloudKitSchemaName)?mode=memory&cache=shared")! } return databaseURL .deletingLastPathComponent() diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index c1eba9ca..b9811ac2 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -8,29 +8,7 @@ import Testing extension BaseCloudKitTests { @MainActor - final class SyncEngineTests: BaseCloudKitTests, @unchecked Sendable { - #if os(macOS) && compiler(>=6.2) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func foreignKeysDisabled() throws { - let result = #expect( - processExitsWith: .failure, - observing: [\.standardErrorContent] - ) { - // TODO: finish in Xcode 26 - // _ = try SyncEngine( - // syncEngine.private: MockSyncEngine(scope: .private, state: MockSyncEngineState()), - // syncEngine.shared: MockSyncEngine(scope: .shared, state: MockSyncEngineState()), - // database: databaseWithForeignKeys(), - // tables: [] - // ) - } - #expect( - String(decoding: try #require(result).standardOutputContent, as: UTF8.self) - == "Foreign key support must be disabled to synchronize with CloudKit." - ) - } - #endif - + final class SyncEngineTests { @Test func inMemory() throws { #expect(URL(string: "")?.isInMemory == nil) #expect(URL(string: ":memory:")?.isInMemory == true) @@ -38,6 +16,25 @@ extension BaseCloudKitTests { #expect(URL(string: "file::memory:")?.isInMemory == true) #expect(URL(string: "file:memdb1?mode=memory&cache=shared")?.isInMemory == true) } + + @Test func inMemoryUserDatabase() async throws { + let syncEngine = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "test", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: DatabaseQueue()), + tables: [] + ) + + try await syncEngine.userDatabase.read { db in + try SQLQueryExpression(""" + SELECT 1 FROM "sqlitedata_icloud_metadata" + """) + .execute(db) + } + } } } From 0db8e2b72ca1928a5881de70f4d0bb36d1ea5b96 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 1 Aug 2025 12:16:32 -0500 Subject: [PATCH 470/581] wip --- Examples/Reminders/Schema.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index d3b7e803..1266c9de 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -119,10 +119,7 @@ func appDatabase() throws -> any DatabaseWriter { #endif } if context == .preview { - database = try DatabaseQueue( - path: URL.documentsDirectory.appending(component: "db.sqlite").path(), - configuration: configuration - ) + database = try DatabaseQueue(configuration: configuration) } else { let path = context == .live From 240e4adbaf4c3f6613384623ddc1d19ddea4b312 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 1 Aug 2025 12:21:27 -0500 Subject: [PATCH 471/581] wip --- .../CloudKitTests/SyncEngineTests.swift | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index b9811ac2..1dcb6c8b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -1,5 +1,6 @@ import CloudKit import CustomDump +import DependenciesTestSupport import Foundation import InlineSnapshotTesting import SharingGRDB @@ -29,12 +30,34 @@ extension BaseCloudKitTests { ) try await syncEngine.userDatabase.read { db in - try SQLQueryExpression(""" + try SQLQueryExpression( + """ SELECT 1 FROM "sqlitedata_icloud_metadata" - """) + """ + ) .execute(db) } } + + @Test(.dependency(\.context, .live)) + func inMemoryUserDatabase_LiveContext() async throws { + let error = await #expect(throws: (any Error).self) { + try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "test", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: DatabaseQueue()), + tables: [] + ) + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + InMemoryDatabase() + """ + } + } } } From ac127aa5643ff205133c57472c97182a7fc7142d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 3 Aug 2025 09:15:48 -0500 Subject: [PATCH 472/581] fix merge --- .../CloudKitTests/SyncEngineTests.swift | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index 7598983c..acf9d5be 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -59,27 +59,6 @@ extension BaseCloudKitTests { } } - @Test func inMemoryUserDatabase() async throws { - let syncEngine = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "test", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ), - userDatabase: UserDatabase(database: DatabaseQueue()), - tables: [] - ) - - try await syncEngine.userDatabase.read { db in - try SQLQueryExpression( - """ - SELECT 1 FROM "sqlitedata_icloud_metadata" - """ - ) - .execute(db) - } - } - @Test func metadatabaseMismatch() async throws { let error = await #expect(throws: (any Error).self) { var configuration = Configuration() @@ -87,7 +66,7 @@ extension BaseCloudKitTests { try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree") } let database = try DatabasePool( - path: NSTemporaryDirectory() + UUID().uuidString, + path: "/tmp/db.sqlite", configuration: configuration ) _ = try await SyncEngine( @@ -104,8 +83,8 @@ extension BaseCloudKitTests { #""" SyncEngine.SchemaError( reason: .metadatabaseMismatch( - attachedPath: "/private/var/folders/vj/bzr5j4ld7cz6jgpphc5kbs8m0000gn/T/.C1938F73-8A6E-40BA-BCF5-A10C07CA1EB6.metadata-iCloud.co.pointfree.sqlite", - syncEngineConfiguredPath: "/var/folders/vj/bzr5j4ld7cz6jgpphc5kbs8m0000gn/T/.C1938F73-8A6E-40BA-BCF5-A10C07CA1EB6.metadata-iCloud.co.point-free.sqlite" + attachedPath: "/private/tmp/.db.metadata-iCloud.co.pointfree.sqlite", + syncEngineConfiguredPath: "/tmp/.db.metadata-iCloud.co.point-free.sqlite" ), debugDescription: "Metadatabase attached in \'prepareDatabase\' does not match metadatabase prepared in \'SyncEngine.init\'. Are the CloudKit container identifiers different?" ) From 20e9cd6cf7c761dff1f56cafb6f6c8c6b67a7aec Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 5 Aug 2025 09:55:36 -0700 Subject: [PATCH 473/581] Improve `SyncEngine.init` (#113) * Improve `SyncEngine.init` - Infer default cloud container using SwiftData - Statically require string identifier primary keyed tables * wip * wip --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 7 +- .../CloudKitPlaygroundApp.swift | 7 +- Examples/CloudKitPlayground/Schema.swift | 4 +- .../xcshareddata/swiftpm/Package.resolved | 29 +++---- Examples/Reminders/RemindersApp.swift | 21 ++--- Examples/Reminders/Schema.swift | 4 +- README.md | 9 +- .../CloudKit/DefaultSyncEngine.swift | 28 +++--- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 65 +++++++++++--- .../Documentation.docc/Articles/CloudKit.md | 86 ++++++++----------- .../Articles/CloudKitSharing.md | 14 +-- .../Articles/ComparisonWithSwiftData.md | 10 +-- .../Documentation.docc/SharingGRDBCore.md | 9 +- 13 files changed, 138 insertions(+), 155 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index fdcf1dc6..e96f652b 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -30,11 +30,8 @@ struct CloudKitDemoApp: App { try! prepareDependencies { $0.defaultDatabase = try appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer(identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo"), - database: $0.defaultDatabase, - tables: [ - Counter.self - ] + for: $0.defaultDatabase, + tables: Counter.self ) } return true diff --git a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift index 1f90aeb0..44c9512f 100644 --- a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift +++ b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift @@ -11,11 +11,8 @@ struct CloudKitPlaygroundApp: App { prepareDependencies { $0.defaultDatabase = try! appDatabase() $0.defaultSyncEngine = try! SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" - ), - database: $0.defaultDatabase, - tables: [ModelA.self, ModelB.self, ModelC.self] + for: $0.defaultDatabase, + tables: ModelA.self, ModelB.self, ModelC.self ) } } diff --git a/Examples/CloudKitPlayground/Schema.swift b/Examples/CloudKitPlayground/Schema.swift index ab63de71..9b38d3f6 100644 --- a/Examples/CloudKitPlayground/Schema.swift +++ b/Examples/CloudKitPlayground/Schema.swift @@ -22,9 +22,7 @@ func appDatabase() throws -> any DatabaseWriter { let database: any DatabaseWriter var configuration = Configuration() configuration.prepareDatabase { db in - try db.attachMetadatabase( - containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" - ) + try db.attachMetadatabase() #if DEBUG db.trace(options: .profile) { if context == .live { diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0acc6d3c..46621b31 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "69853b99c9eb6c69968432d61f94ae83a716bf02937dd3eaea7567cdf3966f5d", + "originHash" : "cfa986227a2051ca83eae9c181301c75e9fcd30d8f199a7ee1c7b269b548e192", "pins" : [ { "identity" : "combine-schedulers", @@ -73,24 +73,6 @@ "version" : "1.9.2" } }, - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-plugin", - "state" : { - "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", - "version" : "1.4.5" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", @@ -154,6 +136,15 @@ "version" : "601.0.1" } }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index a1c89133..115e51f3 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,6 +1,7 @@ import CloudKit import SharingGRDB import SwiftUI +import UIKit @main struct RemindersApp: App { @@ -13,17 +14,12 @@ struct RemindersApp: App { try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.SQLiteData.demos.field-timestamps-2.Reminders" - ), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - RemindersListAsset.self, - Reminder.self, - Tag.self, - ReminderTag.self, - ] + for: $0.defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self ) } } @@ -40,9 +36,6 @@ struct RemindersApp: App { } } - -import UIKit - class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { func application( _ application: UIApplication, diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 1266c9de..9b6be2df 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -105,9 +105,7 @@ func appDatabase() throws -> any DatabaseWriter { let database: any DatabaseWriter var configuration = Configuration() configuration.prepareDatabase { db in - try db.attachMetadatabase( - containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.field-timestamps-2.Reminders" - ) + try db.attachMetadatabase() #if DEBUG db.trace(options: .profile) { if context == .live { diff --git a/README.md b/README.md index db3d6062..812dfe1d 100644 --- a/README.md +++ b/README.md @@ -260,13 +260,8 @@ struct MyApp: App { prepareDependencies { $0.defaultDatabase = try! appDatabase() $0.defaultSyncEngine = SyncEngine( - container: CKContainer( - identifier: "iCloud.co.mycompany.MyApp" - ), - database: $0.defaultDatabase, - tables: [ - Item.self, - ] + for: $0.defaultDatabase, + tables: Item.self ) } } diff --git a/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift index f641e3fc..7fe1a580 100644 --- a/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift @@ -1,20 +1,20 @@ #if canImport(CloudKit) -import CloudKit -import Dependencies -import GRDB + import CloudKit + import Dependencies + import GRDB -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension DependencyValues { - public var defaultSyncEngine: SyncEngine { - get { self[SyncEngine.self] } - set { self[SyncEngine.self] = newValue } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension DependencyValues { + public var defaultSyncEngine: SyncEngine { + get { self[SyncEngine.self] } + set { self[SyncEngine.self] = newValue } + } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine: TestDependencyKey { - public static var testValue: SyncEngine { - try! SyncEngine(container: .default(), database: DatabaseQueue(), tables: []) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine: TestDependencyKey { + public static var testValue: SyncEngine { + try! SyncEngine(for: DatabaseQueue()) + } } -} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6dd0951f..790b4f98 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -4,6 +4,8 @@ import CustomDump import OrderedCollections import OSLog + import StructuredQueriesCore + import SwiftData @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final class SyncEngine: Sendable { @@ -24,14 +26,41 @@ let dataManager = Dependency(\.dataManager) - public convenience init( - container: CKContainer, + public convenience init( + for database: any DatabaseWriter, + tables: repeat (each T1).Type, + privateTables: repeat (each T2).Type, + containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), - database: any DatabaseWriter, - logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] - ) throws { + logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit") + ) throws + where + repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, + repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible + { + let containerIdentifier = containerIdentifier + ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier + + guard let containerIdentifier else { + throw SchemaError( + reason: .noCloudKitContainer, + debugDescription: """ + No default CloudKit container found. Please add a container identifier to your app's \ + entitlements. + """ + ) + } + + let container = CKContainer(identifier: containerIdentifier) + var allTables: [any PrimaryKeyedTable.Type] = [] + var allPrivateTables: [any PrimaryKeyedTable.Type] = [] + for table in repeat each tables { + allTables.append(table) + } + for privateTable in repeat each privateTables { + allPrivateTables.append(privateTable) + } + let userDatabase = UserDatabase(database: database) try self.init( container: container, @@ -66,8 +95,8 @@ }, userDatabase: userDatabase, logger: logger, - tables: tables, - privateTables: privateTables + tables: allTables, + privateTables: allPrivateTables ) _ = try setUpSyncEngine( userDatabase: userDatabase, @@ -1483,7 +1512,7 @@ /// func appDatabase() -> any DatabaseWriter { /// var configuration = Configuration() /// configuration.prepareDatabase = { db in - /// db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") + /// db.attachMetadatabase() /// … /// } /// } @@ -1493,7 +1522,20 @@ /// /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize /// data. - public func attachMetadatabase(containerIdentifier: String) throws { + public func attachMetadatabase(containerIdentifier: String? = nil) throws { + let containerIdentifier = containerIdentifier + ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier + + guard let containerIdentifier else { + throw SyncEngine.SchemaError( + reason: .noCloudKitContainer, + debugDescription: """ + No default CloudKit container found. Please add a container identifier to your app's \ + entitlements. + """ + ) + } + let databasePath = try SQLQueryExpression( """ SELECT "file" FROM pragma_database_list() @@ -1539,6 +1581,7 @@ case invalidForeignKeyAction(ForeignKey) case invalidTableName(String) case metadatabaseMismatch(attachedPath: String, syncEngineConfiguredPath: String) + case noCloudKitContainer case nonNullColumnsWithoutDefault(tableName: String, columnNames: [String]) case triggersWithoutSynchronizationCheck([String]) case unknown diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index aaeb2f4d..abaa1d20 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -11,43 +11,43 @@ to make, and so an abundance of care must be taken to make sure all devices rema and capable of communicating with each other. Please read the documentation closely and thoroughly to make sure you understand how to best prepare your app for cloud synchronization. -- [Setting up your project](#Setting-up-your-project) -- [Setting up a SyncEngine](#Setting-up-a-SyncEngine) -- [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) - - [Primary keys](#Primary-keys) - - [Primary keys on every table](#Primary-keys-on-every-table) - - [Foreign key relationships](#Foreign-key-relationships) -- [Record conflicts](#Record-conflicts) -- [Backwards compatible migrations](#Backwards-compatible-migrations) - - [Adding tables](#Adding-tables) - - [Adding columns](#Adding-columns) - - [Disallowed migrations](#Disallowed-migrations) -- [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) -- [Assets](#Assets) -- [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) -- [How SharingGRDB handles distributed schema scenarios](#How-SharingGRDB-handles-distributed-schema-scenarios) -- [Unit testing and Xcode previews](#Unit-testing-and-Xcode-previews) -- [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) - - [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) - - [Add primary key to all tables](#Add-primary-key-to-all-tables) -- [Migrating from Swift Data to SharingGRDB](#Migrating-from-Swift-Data-to-SharingGRDB) -- [Separating schema migrations from data migrations](#Separating-schema-migrations-from-data-migrations) -- [Tips and tricks](#Tips-and-tricks) - - [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization) -- [Topics](#Topics) - - [Go deeper](#Go-deeper) + * [Setting up your project](#Setting-up-your-project) + * [Setting up a SyncEngine](#Setting-up-a-SyncEngine) + * [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) + * [Primary keys](#Primary-keys) + * [Primary keys on every table](#Primary-keys-on-every-table) + * [Foreign key relationships](#Foreign-key-relationships) + * [Record conflicts](#Record-conflicts) + * [Backwards compatible migrations](#Backwards-compatible-migrations) + * [Adding tables](#Adding-tables) + * [Adding columns](#Adding-columns) + * [Disallowed migrations](#Disallowed-migrations) + * [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) + * [Assets](#Assets) + * [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) + * [How SharingGRDB handles distributed schema scenarios](#How-SharingGRDB-handles-distributed-schema-scenarios) + * [Unit testing and Xcode previews](#Unit-testing-and-Xcode-previews) + * [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) + * [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) + * [Add primary key to all tables](#Add-primary-key-to-all-tables) + * [Migrating from Swift Data to SharingGRDB](#Migrating-from-Swift-Data-to-SharingGRDB) + * [Separating schema migrations from data migrations](#Separating-schema-migrations-from-data-migrations) + * [Tips and tricks](#Tips-and-tricks) + * [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization) + * [Topics](#Topics) + * [Go deeper](#Go-deeper) ## Setting up your project The steps to set up your SharingGRDB project for CloudKit synchronization are the [same for setting up][setup-cloudkit-apple] any other kind of project for CloudKit: -* Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project. -* Follow the [Configuring background execution modes] guide for adding the Background Modes -capability to your project. -* If you want to enable sharing of records with other iCloud users, be sure to add a -`CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented -in [Apple's documentation for sharing]. + * Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project. + * Follow the [Configuring background execution modes] guide for adding the Background Modes + capability to your project. + * If you want to enable sharing of records with other iCloud users, be sure to add a + `CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented + in [Apple's documentation for sharing]. With those steps completed, you are ready to configure a ``SyncEngine`` that will facilitate synchronizing your database to and from CloudKit. @@ -78,32 +78,22 @@ struct MyApp: App { try! prepareDependencies { $0.defaultDatabase = try appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" - ), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - Reminder.self, - ] + for: $0.defaultDatabase, + tables: RemindersList.self, Reminder.self ) } } - … + // ... } ``` The `SyncEngine` - [initializer]() +[initializer]() has more options you may be interested in configuring. -> Important: A few important things to note about this: -> -> * The CloudKit container identifier must be explicitly provided and unfortunately cannot be -> extracted from Entitlements.plist automatically. That privilege is only afforded to SwiftData. -> * You must explicitly provide all tables that you want to synchronize. We do this so that you can -> have the option of having some local tables that are not synchronized to CloudKit. +> Important: You must explicitly provide all tables that you want to synchronize. We do this so that +> you can have the option of having some local tables that are not synchronized to CloudKit. Once this work is done the app should work exactly as it did before, but now any changes made to the database will be synchronized to CloudKit. You will still interact with your local SQLite @@ -118,7 +108,7 @@ you can use the `prepareDatabase` method on `Configuration` to attach the metada func appDatabase() -> any DatabaseWriter { var configuration = Configuration() configuration.prepareDatabase = { db in - db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") + db.attachMetadatabase() … } } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md index 4072ba3a..f4121aa0 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md @@ -391,17 +391,9 @@ struct MyApp: App { try! prepareDependencies { $0.defaultDatabase = try appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" - ), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - Reminder.self, - ], - privateTables: [ - RemindersListPrivate.self - ] + for: $0.defaultDatabase, + tables: RemindersList.self, Reminder.self, + privateTables: RemindersListPrivate.self ) } } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md index 6934e915..7420f6b9 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -797,14 +797,8 @@ inspect the Entitlements.plist in order to automatically extract that informatio try! prepareDependencies { $0.defaultDatabase = try appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" - ), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - Reminder.self, - ] + for: $0.defaultDatabase, + tables: RemindersList.self, Reminder.self ) } } diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md index ef52d9c5..f8f968c5 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md @@ -184,13 +184,8 @@ struct MyApp: App { prepareDependencies { $0.defaultDatabase = try! appDatabase() $0.defaultSyncEngine = SyncEngine( - container: CKContainer( - identifier: "iCloud.co.mycompany.MyApp" - ), - database: $0.defaultDatabase, - tables: [ - /* ... */ - ] + for: $0.defaultDatabase, + tables: /* ... */ ) } } From fc2e9c5e3234f81506aaade6404bd91555590874 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 5 Aug 2025 15:09:48 -0700 Subject: [PATCH 474/581] fix test --- .../CloudKitTests/TriggerTests.swift | 854 +++++++++--------- 1 file changed, 428 insertions(+), 426 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index acee233c..0494b499 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -13,432 +13,434 @@ extension BaseCloudKitTests { let triggersAfterSetUp = try await userDatabase.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } - assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { - #""" - [ - [0]: """ - CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" - AFTER DELETE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - SELECT sqlitedata_icloud_didDelete("old"."recordName", coalesce("old"."lastKnownServerRecord", ( - WITH "ancestorMetadatas" AS ( - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."recordName") - UNION ALL - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") - ) - SELECT "ancestorMetadatas"."lastKnownServerRecord" - FROM "ancestorMetadatas" - WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - ))); - END - """, - [1]: """ - CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" - AFTER INSERT ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( - WITH "ancestorMetadatas" AS ( - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") - UNION ALL - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") - ) - SELECT "ancestorMetadatas"."lastKnownServerRecord" - FROM "ancestorMetadatas" - WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - ))); - END - """, - [2]: """ - CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" - AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( - WITH "ancestorMetadatas" AS ( - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") - UNION ALL - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") - ) - SELECT "ancestorMetadatas"."lastKnownServerRecord" - FROM "ancestorMetadatas" - WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - ))); - END - """, - [3]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" - AFTER DELETE ON "childWithOnDeleteSetDefaults" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); - END - """, - [4]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" - AFTER DELETE ON "childWithOnDeleteSetNulls" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); - END - """, - [5]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs" - AFTER DELETE ON "modelAs" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); - END - """, - [6]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs" - AFTER DELETE ON "modelBs" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); - END - """, - [7]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs" - AFTER DELETE ON "modelCs" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); - END - """, - [8]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" - AFTER DELETE ON "parents" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); - END - """, - [9]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags" - AFTER DELETE ON "reminderTags" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); - END - """, - [10]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" - AFTER DELETE ON "reminders" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); - END - """, - [11]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets" - AFTER DELETE ON "remindersListAssets" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); - END - """, - [12]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" - AFTER DELETE ON "remindersListPrivates" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); - END - """, - [13]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" - AFTER DELETE ON "remindersLists" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); - END - """, - [14]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags" - AFTER DELETE ON "tags" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); - END - """, - [15]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" - AFTER INSERT ON "childWithOnDeleteSetDefaults" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [16]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" - AFTER INSERT ON "childWithOnDeleteSetNulls" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [17]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" - AFTER INSERT ON "modelAs" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelAs', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [18]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" - AFTER INSERT ON "modelBs" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [19]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" - AFTER INSERT ON "modelCs" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [20]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" - AFTER INSERT ON "parents" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'parents', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [21]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" - AFTER INSERT ON "reminderTags" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'reminderTags', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [22]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" - AFTER INSERT ON "reminders" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [23]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" - AFTER INSERT ON "remindersListAssets" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [24]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" - AFTER INSERT ON "remindersListPrivates" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [25]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" - AFTER INSERT ON "remindersLists" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersLists', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [26]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" - AFTER INSERT ON "tags" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'tags', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [27]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" - AFTER UPDATE ON "childWithOnDeleteSetDefaults" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [28]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" - AFTER UPDATE ON "childWithOnDeleteSetNulls" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [29]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" - AFTER UPDATE ON "modelAs" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelAs', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [30]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" - AFTER UPDATE ON "modelBs" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [31]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" - AFTER UPDATE ON "modelCs" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [32]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" - AFTER UPDATE ON "parents" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'parents', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [33]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" - AFTER UPDATE ON "reminderTags" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'reminderTags', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [34]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" - AFTER UPDATE ON "reminders" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [35]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" - AFTER UPDATE ON "remindersListAssets" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [36]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" - AFTER UPDATE ON "remindersListPrivates" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [37]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" - AFTER UPDATE ON "remindersLists" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersLists', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [38]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" - AFTER UPDATE ON "tags" - FOR EACH ROW BEGIN - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'tags', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """ - ] - """# - } + #if DEBUG + assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { + #""" + [ + [0]: """ + CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" + AFTER DELETE ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + SELECT sqlitedata_icloud_didDelete("old"."recordName", coalesce("old"."lastKnownServerRecord", ( + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) + ))); + END + """, + [1]: """ + CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" + AFTER INSERT ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) + ))); + END + """, + [2]: """ + CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" + AFTER UPDATE ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) + ))); + END + """, + [3]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" + AFTER DELETE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [4]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" + AFTER DELETE ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [5]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs" + AFTER DELETE ON "modelAs" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [6]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs" + AFTER DELETE ON "modelBs" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [7]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs" + AFTER DELETE ON "modelCs" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [8]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" + AFTER DELETE ON "parents" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [9]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags" + AFTER DELETE ON "reminderTags" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); + END + """, + [10]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" + AFTER DELETE ON "reminders" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [11]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets" + AFTER DELETE ON "remindersListAssets" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [12]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" + AFTER DELETE ON "remindersListPrivates" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [13]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" + AFTER DELETE ON "remindersLists" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [14]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags" + AFTER DELETE ON "tags" + FOR EACH ROW BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [15]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" + AFTER INSERT ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [16]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" + AFTER INSERT ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [17]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" + AFTER INSERT ON "modelAs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelAs', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [18]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" + AFTER INSERT ON "modelBs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" + AFTER INSERT ON "modelCs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [20]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" + AFTER INSERT ON "parents" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'parents', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [21]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" + AFTER INSERT ON "reminderTags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminderTags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [22]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" + AFTER INSERT ON "reminders" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [23]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" + AFTER INSERT ON "remindersListAssets" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [24]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" + AFTER INSERT ON "remindersListPrivates" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [25]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" + AFTER INSERT ON "remindersLists" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersLists', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [26]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" + AFTER INSERT ON "tags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'tags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [27]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" + AFTER UPDATE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [28]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" + AFTER UPDATE ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [29]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" + AFTER UPDATE ON "modelAs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelAs', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [30]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" + AFTER UPDATE ON "modelBs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [31]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" + AFTER UPDATE ON "modelCs" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [32]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" + AFTER UPDATE ON "parents" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'parents', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [33]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" + AFTER UPDATE ON "reminderTags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminderTags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [34]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" + AFTER UPDATE ON "reminders" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [35]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" + AFTER UPDATE ON "remindersListAssets" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [36]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" + AFTER UPDATE ON "remindersListPrivates" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [37]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersLists', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [38]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" + AFTER UPDATE ON "tags" + FOR EACH ROW BEGIN + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'tags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """ + ] + """# + } + #endif try await syncEngine.tearDownSyncEngine() let triggersAfterTearDown = try await userDatabase.userWrite { db in From 53fb7fb5d13fccdf72248ddf154362dde2fb1f2f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 5 Aug 2025 15:12:15 -0700 Subject: [PATCH 475/581] wip --- .github/workflows/ci.yml | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c354fe9..1b7a4bb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,19 +44,20 @@ jobs: - name: xcodebuild ${{ matrix.scheme }} run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="${{ matrix.scheme }}" xcodebuild-raw - linux: - name: Linux - strategy: - matrix: - swift: - - '6.1' - runs-on: ubuntu-latest - container: swift:${{ matrix.swift }} - steps: - - uses: actions/checkout@v4 - - name: Install Build Dependencies - run: | - apt-get update - apt-get install -y libsqlite3-dev - - name: Build - run: swift build + # Bring back when GRDB successfully compiles on Linux again + # linux: + # name: Linux + # strategy: + # matrix: + # swift: + # - '6.1' + # runs-on: ubuntu-latest + # container: swift:${{ matrix.swift }} + # steps: + # - uses: actions/checkout@v4 + # - name: Install Build Dependencies + # run: | + # apt-get update + # apt-get install -y libsqlite3-dev + # - name: Build + # run: swift build From 5d9290f9310d645c8f89da9c5def5ff2da3d9b1a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 5 Aug 2025 15:21:03 -0700 Subject: [PATCH 476/581] wip --- .../Documentation.docc/Articles/CloudKit.md | 79 ++++++++++--------- .../Articles/CloudKitSharing.md | 22 +++--- 2 files changed, 51 insertions(+), 50 deletions(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index abaa1d20..be0d424a 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -131,18 +131,18 @@ version. #### Primary keys -> TLDR: Primary keys should be globally unique identifiers, such as UUID. We further recommend -> specifying a "NOT NULL" constraint with a "ON CONFLICT REPLACE" action. +> TL;DR: Primary keys should be globally unique identifiers, such as UUID. We further recommend +> specifying a `NOT NULL` constraint with a `ON CONFLICT REPLACE` action. Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a -primary key by using an "autoincrement" integer. This makes it so that newly inserted rows get +primary key by using an `AUTOINCREMENT` integer. This makes it so that newly inserted rows get a unique ID by simply adding 1 to the largest ID in the table. However, that does not play nicely with distributed schemas. That would make it possible for two devices to create a record with `id: 1`, and when those records synchronize there would be an irreconcilable conflict. For this reason, primary keys in SQLite tables should be globally unique, such as a UUID. The -easiest way to do this is to store your table's ID in a "TEXT" column, adding a -default with a freshly generated UUID, and further adding a "ON CONFLICT REPLACE" constraint: +easiest way to do this is to store your table's ID in a `TEXT` column, adding a +default with a freshly generated UUID, and further adding a `ON CONFLICT REPLACE` constraint: ```sql CREATE TABLE "reminders" ( @@ -151,7 +151,7 @@ CREATE TABLE "reminders" ( ) ``` -> Tip: The "ON CONFLICT REPLACE" clause must be placed directly after "NOT NULL". +> Tip: The `ON CONFLICT REPLACE` clause must be placed directly after `NOT NULL`. This allows you to insert a row with a NULL value for the primary key and SQLite will compute the primary key from the default value specified. This kind of pattern is commonly used with the @@ -160,10 +160,10 @@ the primary key from the default value specified. This kind of pattern is common ```swift try database.write { db in try Reminder.upsert { - // Do not provide 'id', let database initialize it for you. - Reminder.Draft(title: "Get milk") - } - .execute(db) + // Do not provide 'id', let database initialize it for you. + Reminder.Draft(title: "Get milk") + } + .execute(db) } ``` @@ -182,7 +182,7 @@ CREATE TABLE "reminders" ( #### Primary keys on every table -> TLDR: Each synchronized table must have a single, non-compound primary key to aid in +> TL;DR: Each synchronized table must have a single, non-compound primary key to aid in > synchronization, even if it is not used by your app. _Every_ table being synchronized must have a single primary key and cannot have compound primary @@ -206,7 +206,7 @@ TODO: think more about this #### Default values for columns -> TLDR: All columns must have a default in order to allow for multiple devices to run your +> TL;DR: All columns must have a default in order to allow for multiple devices to run your > app with different versions of the schema. Your tables' schemas should be defined to provide a default for every non-null column. To see why @@ -221,7 +221,7 @@ a ``NonNullColumnMustHaveDefault`` error will be thrown. #### Unique constraints -> TLDR: SQLite tables cannot have "UNIQUE" constraints on their columns in order to allow +> TL;DR: SQLite tables cannot have `UNIQUE` constraints on their columns in order to allow > for distributed creation of records. Tables with unique constraints on their columns, other than on the primary key, cannot be @@ -238,7 +238,7 @@ when a ``SyncEngine`` is first created. If a uniqueness constraint is detected a #### Foreign key relationships -> TLDR: Foreign key constraints can be enabled and you can use "ON DELETE" actions to +> TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to > cascade deletions. SharingGRDB can synchronize many-to-one and many-to-many relationships to CloudKit, @@ -248,13 +248,13 @@ such as receiving a child record before its parent, the sync engine will cache t until the parent record has been synchronized, at which point the child record will also be synchronized. -Currently the only actions supported for "ON DELETE" are "CASCADE", "SET NULL" and "SET DEFAULT". -In particular, "RESTRICT" and "NO ACTION" are not supported, and if you try to use those actions -in your schema an ``InvalidParentForeignKey`` error will be thrown when constructing ``SyncEngine``. +Currently the only actions supported for `ON DELETE` are `CASCADE`, `SET NULL` and `SET DEFAULT`. +In particular, `RESTRICT` and `NO ACTION` are not supported, and if you try to use those actions +in your schema an error will be thrown when constructing ``SyncEngine``. ## Record conflicts -> TLDR: Conflicts are handled automatically using a "last edit wins" strategy for each +> TL;DR: Conflicts are handled automatically using a "last edit wins" strategy for each > column of the record. Conflicts between record edits will inevitably happen, and it's just a fact of dealing with @@ -269,7 +269,7 @@ the only strategy available and we feel serves the needs of the most number of p ## Backwards compatible migrations -> TLDR: Database migrations should be done carefully and with full backwards compatibility +> TL;DR: Database migrations should be done carefully and with full backwards compatibility > in mind in order to support multiple devices running with different schema versions. Migrations of a distributed schema come with even more complications than what is mentioned above. @@ -286,9 +286,9 @@ has been added to the schema, it will populate the table with the cached records #### Adding columns -> TLDR: When adding columns to a table that has already been deployed to user's devices, you will -either need to make the column nullable, or it can be "NOT NULL" but a default value must be -provided with an "ON CONFLICT REPLACE" clause. +> TL;DR: When adding columns to a table that has already been deployed to user's devices, you will +either need to make the column nullable, or it can be `NOT NULL` but a default value must be +provided with an `ON CONFLICT REPLACE` clause. As an example, suppose the 1.0 of your app shipped a table for a reminders list: @@ -338,10 +338,10 @@ VALUES (NULL, 'Personal', NULL) ``` -This will generate a SQL error because the "position" column was declared as "NOT NULL", and so this +This will generate a SQL error because the "position" column was declared as `NOT NULL`, and so this record will not properly synchronize to devices running a newer version of the app. -The fix is to allow for inserting "NULL" values into "NOT NULL" columns by using the default of the +The fix is to allow for inserting `NULL` values into `NOT NULL` columns by using the default of the column. This can be done like so: ```sql @@ -349,7 +349,7 @@ ALTER TABLE "remindersLists" ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 ``` -> Important: The "ON CONFLICT REPLACE" clause must come directly after "NOT NULL" because it +> Important: The `ON CONFLICT REPLACE` clause must come directly after `NOT NULL` because it > modifies that constraint. Now when this query is executed: @@ -361,7 +361,7 @@ VALUES (NULL, 'Personal', NULL) ``` -…it will use 0 for the "position" column. +…it will use 0 for the `position` column. Sometimes it is not possible to specify a default for a newly added column. Suppose in version 1.2 of your app you add groups for reminders lists. This can be expressed as a new field on the @@ -410,7 +410,7 @@ And your migration will need to add a nullable column to the table: REFERENCES "remindersListGroups"("id") ``` -It may be disappointing to have to weaken your domain modeling to accomodate synchronization, but +It may be disappointing to have to weaken your domain modeling to accommodate synchronization, but that is the unfortunate reality of a distributed schema. In order to allow multiple versions of your schema to be run on devices so that each device can create new records and edit existing records that all devices can see, you will need to make some compromises. @@ -420,9 +420,9 @@ that all devices can see, you will need to make some compromises. Certain kinds of migrations are simply not allowed when synchronizing your schema to multiple devices. They are: -* Removing columns -* Renaming columns -* Renaming tables + * Removing columns + * Renaming columns + * Renaming tables ## Sharing records with other iCloud users @@ -436,7 +436,7 @@ See for more information. ## Assets -> TLDR: The library packages all BLOB columns in a table into `CKAsset`s and seamlessly decodes +> TL;DR: The library packages all BLOB columns in a table into `CKAsset`s and seamlessly decodes > `CKAsset`s back into your tables. We recommend putting large binary blobs of data in their own > tables. @@ -495,7 +495,7 @@ to construct a SQL query for fetching the meta data associated with one of your For example, if you want to retrieve the `CKRecord` that is associated with a particular row in one of your tables, say a reminder, then you can use ``SyncMetadata/lastKnownServerRecord`` to -retreive the `CKRecord` and then invoke a CloudKit database function to retreive all of the details: +retrieve the `CKRecord` and then invoke a CloudKit database function to retrieve all of the details: ```swift let lastKnownServerRecord = try database.read { db in @@ -516,12 +516,12 @@ let ckRecord = try await container.privateCloudDatabase > a shared record, which can be determined from [SyncMetadata.share](), > then you must use `sharedCloudDatabase` to fetch the newest record. -You are free to invoke any CloudKit functions you want with the `CKRecord` retreived from +You are free to invoke any CloudKit functions you want with the `CKRecord` retrieved from ``SyncMetadata``. Any changes made directly with CloudKit will be automatically synced to your SQLite database by the ``SyncEngine``. It is also possible to fetch the `CKShare` associated with a record if it has been shared, which -will give you access to the most current list of paricipants and permissions for the shared record: +will give you access to the most current list of participants and permissions for the shared record: ```swift let share = try database.read { db in @@ -586,14 +586,16 @@ return true if the write to your database originates from the sync engine. You c trigger like so: ```swift -#sql(""" +#sql( + """ CREATE TEMPORARY TRIGGER "…" - AFTER DELETE ON "…"" + AFTER DELETE ON "…" FOR EACH ROW WHEN NOT \(SyncEngine.isSynchronizingChanges()) BEGIN … END - """) + """ +) ``` Or if you are using the trigger building tools from [StructuredQueries] you can use it like so: @@ -602,9 +604,8 @@ Or if you are using the trigger building tools from [StructuredQueries] you can ```swift Model.createTemporaryTrigger( - "…", after: .insert { new in - … + // ... } when: { _ in !SyncEngine.isSynchronizingChanges() } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md index f4121aa0..0c9c0e7c 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md @@ -50,7 +50,7 @@ struct RemindersListView: View { Task { await withErrorReporting { sharedRecord = try await syncEngine.share(record: remindersList) { share in - share[CKShare.SystemFieldKey.title] = "Join '\(remindersList.title)!'" + share[CKShare.SystemFieldKey.title] = "Join '\(remindersList.title)'!" } } } @@ -104,7 +104,7 @@ shared record. There is, however, a lot more to know about sharing. There are im placed on what kind of records you are allowed to share, and what associations of those records are shared. -In a nutshell, only "root" records can be directly shared, i.e. records with no foreign keys. +In a nutshell, only "root" records can be directly shared, _i.e._ records with no foreign keys. Further, an association of a root record can only be shared if it has only one foreign key pointing to the root record. And this last rule applies recursively: a leaf association is shared only if it has exactly one foreign key pointing to a record that also satisfies this property. @@ -113,7 +113,7 @@ For more in-depth information, keep reading. ### Sharing root records -> Important: It is only possible to share "root" records, i.e. records with no foreign keys. +> Important: It is only possible to share "root" records, _i.e._ records with no foreign keys. A record can be shared only if it is a "root" record. That means it cannot have any foreign keys whatsoever. As an example, the following `RemindersList` table is a root record because @@ -141,16 +141,16 @@ struct Reminder: Identifiable { ``` Such records cannot be shared because it is not appropriate to also share the parent record -(i.e. the reminders list). +(_i.e._ the reminders list). For example, suppose you have a list named "Personal" with a reminder "Get milk". If you share this reminder with someone, then it becomes difficult to figure out what to do when they make certain changes to the reminder: -* If they decide to reassign the reminder to their personal "Life" list, what should -happen? Should their "Life" list suddenly be synchronized to your device? -* Or what if they delete the list? Would you want that to delete your list and all of the reminders -in the list? + * If they decide to reassign the reminder to their personal "Life" list, what should + happen? Should their "Life" list suddenly be synchronized to your device? + * Or what if they delete the list? Would you want that to delete your list and all of the reminders + in the list? For these reasons, and more, it is not possible to share non-root records, like reminders. Instead, you can share root records, like reminders lists. If you do invoke @@ -237,7 +237,7 @@ As a more complex example, consider the following diagrammatic schema: In this schema, a `RemindersList` can have many `Reminder`s and a `CoverImage`, and a `Reminder` can have many `ChildReminder`s. Sharing a `RemindersList` will share all associated reminders, -cover image, and even child reminderes. The child reminders are synchronized because it has a +cover image, and even child reminders. The child reminders are synchronized because it has a single foreign key pointing to a table that also has a single foreign key pointing to the root record. @@ -347,8 +347,8 @@ it is also the primary key of the table it enforces that at most one cover image ## Controlling what data is shared It is possible to specify that certain associations that are shareable not be shared. For example, -suppose that you want reminders lists to be orderable by your user, and so add a `position` -column to the table: +suppose that you want reminders lists to be sorted by your user, and so add a `position` column to +the table: ```swift @Table From 29930e7f74e2330118e32a78d44c48a8f40266b8 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 8 Aug 2025 16:42:21 -0700 Subject: [PATCH 477/581] wip --- Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index be0d424a..51043dc9 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -619,4 +619,4 @@ from CloudKit. ### Go deeper -- +- From ccb2444f7c9069b3668927747ebed2988311c848 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:30:08 -0500 Subject: [PATCH 478/581] Regenerate macro code for CloudKit tables. (#120) * Regenerate macro code for CloudKit tables. * formatting * fix warnings * fix typo * wip --- .../xcshareddata/swiftpm/Package.resolved | 20 ++++++- Package.resolved | 6 +- Package.swift | 2 +- .../CloudKit/RecordType+MacroExpansion.swift | 32 +++++++---- .../StateSerialization+MacroExpansion.swift | 56 +++++++++++++------ .../SyncMetadata+MacroExpansion.swift | 51 +++++++++++------ .../CloudKit/SyncMetadata.swift | 21 ++++--- .../SharingGRDBCore/CloudKit/Triggers.swift | 10 ++-- .../UnsyncedRecordID+MacroExpansion.swift | 29 +++++++--- .../CloudKitTests/AccountLifecycleTests.swift | 2 +- .../ReferenceViolationTests.swift | 2 +- 11 files changed, 158 insertions(+), 73 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b78c0c5e..5e9d9dc3 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "045453ad987825e0124358340f9eb671db13456ca8713bb035043d7c4e34e24b", + "originHash" : "05461ecd2d6ddd848b677fd572ee263adc87ab3aebd38d91f3f4c49ddaf3cdde", "pins" : [ { "identity" : "combine-schedulers", @@ -73,6 +73,24 @@ "version" : "1.9.2" } }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", diff --git a/Package.resolved b/Package.resolved index c190d59d..731ae517 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b65215507a456e8eb22717bbb2cbc48bd7cf35bae37178bf7fd94e4426160680", + "originHash" : "304d33f17363a8d91fc9762f429327f95ab8d8ea5577c2120d53772b7ac70814", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "main", - "revision" : "a2a7c06f5b3c60811558bd9552fbbbd0cc027d91" + "branch" : "sendable-triggers", + "revision" : "c56fe85c2963d0beb22a9a8503a01989f4c0a866" } }, { diff --git a/Package.swift b/Package.swift index 62a3f7ad..b8eca291 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "main"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "sendable-triggers"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ diff --git a/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift index c9aec4e2..cacce5ac 100644 --- a/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift @@ -2,8 +2,8 @@ import StructuredQueriesCore extension RecordType { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore - .PrimaryKeyedTableDefinition + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition, + StructuredQueriesCore.PrimaryKeyedTableDefinition { public typealias QueryValue = RecordType public let tableName = StructuredQueriesCore.TableColumn( @@ -36,7 +36,7 @@ package let tableName: String? package let schema: String package let tableInfo: Set - public struct TableColumns: StructuredQueriesCore.TableDefinition { + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Draft public let tableName = StructuredQueriesCore.TableColumn( "tableName", @@ -60,11 +60,15 @@ "\(self.tableName), \(self.schema), \(self.tableInfo)" } } - public static let columns = TableColumns() + public nonisolated static var columns: TableColumns { + TableColumns() + } - public static let tableName = RecordType.tableName + public nonisolated static var tableName: String { + RecordType.tableName + } - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { self.tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) let tableInfo = try decoder.decode(Set.JSONRepresentation.self) @@ -78,7 +82,7 @@ self.tableInfo = tableInfo } - public init(_ other: RecordType) { + public nonisolated init(_ other: RecordType) { self.tableName = other.tableName self.schema = other.schema self.tableInfo = other.tableInfo @@ -95,10 +99,16 @@ } } - extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { - public static let columns = TableColumns() - public static let tableName = "sqlitedata_icloud_recordTypes" - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + nonisolated extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore + .PrimaryKeyedTable + { + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "sqlitedata_icloud_recordTypes" + } + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let tableName = try decoder.decode(String.self) let schema = try decoder.decode(String.self) let tableInfo = try decoder.decode(Set.JSONRepresentation.self) diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift index 05c1d19e..4abf88a3 100644 --- a/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift @@ -4,11 +4,19 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension StateSerialization { - public struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition { + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition, + StructuredQueriesCore.PrimaryKeyedTableDefinition + { public typealias QueryValue = StateSerialization - public let scope = StructuredQueriesCore.TableColumn("scope", keyPath: \QueryValue.scope) - public let data = StructuredQueriesCore.TableColumn("data", keyPath: \QueryValue.data) - public var primaryKey: StructuredQueriesCore.TableColumn { + public let scope = StructuredQueriesCore.TableColumn< + QueryValue, CKDatabase.Scope.RawValueRepresentation + >("scope", keyPath: \QueryValue.scope) + public let data = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation + >("data", keyPath: \QueryValue.data) + public var primaryKey: + StructuredQueriesCore.TableColumn + { self.scope } public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { @@ -26,25 +34,34 @@ public typealias PrimaryTable = StateSerialization package var scope: CKDatabase.Scope? package var data: CKSyncEngine.State.Serialization - public struct TableColumns: StructuredQueriesCore.TableDefinition { + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = Draft - public let scope = StructuredQueriesCore.TableColumn("scope", keyPath: \QueryValue.scope) - public let data = StructuredQueriesCore.TableColumn("data", keyPath: \QueryValue.data) + public let scope = StructuredQueriesCore.TableColumn< + QueryValue, CKDatabase.Scope.RawValueRepresentation? + >("scope", keyPath: \QueryValue.scope) + public let data = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation + >("data", keyPath: \QueryValue.data) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.scope, QueryValue.columns.data] } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] + { [QueryValue.columns.scope, QueryValue.columns.data] } public var queryFragment: QueryFragment { "\(self.scope), \(self.data)" } } - public static let columns = TableColumns() + public nonisolated static var columns: TableColumns { + TableColumns() + } - public static let tableName = StateSerialization.tableName + public nonisolated static var tableName: String { + StateSerialization.tableName + } - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { self.scope = try decoder.decode(CKDatabase.Scope.RawValueRepresentation.self) let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) guard let data else { @@ -53,7 +70,7 @@ self.data = data } - public init(_ other: StateSerialization) { + public nonisolated init(_ other: StateSerialization) { self.scope = other.scope self.data = other.data } @@ -67,10 +84,17 @@ } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable { - public static let columns = TableColumns() - public static let tableName = "sqlitedata_icloud_stateSerialization" - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + nonisolated extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore + .PrimaryKeyedTable + { + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "sqlitedata_icloud_stateSerialization" + } + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let scope = try decoder.decode(CKDatabase.Scope.RawValueRepresentation.self) let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) guard let scope else { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index f2c488aa..3621ee08 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -3,7 +3,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - public struct TableColumns: StructuredQueriesCore.TableDefinition { + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = SyncMetadata public let recordPrimaryKey = StructuredQueriesCore.TableColumn( "recordPrimaryKey", @@ -36,7 +36,7 @@ public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< QueryValue, CKRecord?.SystemFieldsRepresentation >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - package let _lastKnownServerRecordAllFields = StructuredQueriesCore.TableColumn< + public let _lastKnownServerRecordAllFields = StructuredQueriesCore.TableColumn< QueryValue, CKRecord?.AllFieldsRepresentation >("_lastKnownServerRecordAllFields", keyPath: \QueryValue._lastKnownServerRecordAllFields) public let share = StructuredQueriesCore.TableColumn< @@ -58,8 +58,7 @@ QueryValue.columns.recordName, QueryValue.columns.parentRecordPrimaryKey, QueryValue.columns.parentRecordType, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, - QueryValue.columns._lastKnownServerRecordAllFields, - QueryValue.columns.share, + QueryValue.columns._lastKnownServerRecordAllFields, QueryValue.columns.share, QueryValue.columns.isShared, QueryValue.columns.userModificationDate, ] } @@ -68,8 +67,7 @@ QueryValue.columns.recordPrimaryKey, QueryValue.columns.recordType, QueryValue.columns.parentRecordPrimaryKey, QueryValue.columns.parentRecordType, QueryValue.columns.lastKnownServerRecord, - QueryValue.columns._lastKnownServerRecordAllFields, - QueryValue.columns.share, + QueryValue.columns._lastKnownServerRecordAllFields, QueryValue.columns.share, QueryValue.columns.userModificationDate, ] } @@ -80,10 +78,14 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncMetadata: StructuredQueriesCore.Table { - public static let columns = TableColumns() - public static let tableName = "sqlitedata_icloud_metadata" - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + nonisolated extension SyncMetadata: StructuredQueriesCore.Table { + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "sqlitedata_icloud_metadata" + } + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordPrimaryKey = try decoder.decode(String.self) let recordType = try decoder.decode(String.self) let recordName = try decoder.decode(String.self) @@ -133,9 +135,9 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncMetadata.AncestorMetadata { + extension AncestorMetadata { public struct Columns: StructuredQueriesCore.QueryExpression { - public typealias QueryValue = SyncMetadata.AncestorMetadata + public typealias QueryValue = AncestorMetadata public let queryFragment: StructuredQueriesCore.QueryFragment public init( recordName: some StructuredQueriesCore.QueryExpression, @@ -150,8 +152,8 @@ } } - public struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = SyncMetadata.AncestorMetadata + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = AncestorMetadata public let recordName = StructuredQueriesCore.TableColumn( "recordName", keyPath: \QueryValue.recordName @@ -182,12 +184,24 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncMetadata.AncestorMetadata: StructuredQueriesCore.Table { - public static let columns = TableColumns() - public static let tableName = "ancestorMetadatas" + nonisolated extension AncestorMetadata: StructuredQueriesCore.Table, StructuredQueriesCore + .PartialSelectStatement + { + public typealias QueryValue = Self + public typealias From = Swift.Never + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "ancestorMetadatas" + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension AncestorMetadata: StructuredQueriesCore.QueryRepresentable { public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordName = try decoder.decode(String.self) - self.parentRecordName = try decoder.decode(String.self) + let parentRecordName = try decoder.decode(String.self) let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) guard let recordName else { throw QueryDecodingError.missingRequiredColumn @@ -196,6 +210,7 @@ throw QueryDecodingError.missingRequiredColumn } self.recordName = recordName + self.parentRecordName = parentRecordName self.lastKnownServerRecord = lastKnownServerRecord } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index ad0d7c82..85676907 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -53,7 +53,7 @@ public var lastKnownServerRecord: CKRecord? /// The last known `CKRecord` received from the server with all fields archived. - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + // @Column(as: CKRecord?.AllFieldsRepresentation.self) package var _lastKnownServerRecordAllFields: CKRecord? /// The `CKShare` associated with this record, if it is shared. @@ -65,7 +65,19 @@ /// The date the user last modified the record. public var userModificationDate: Date + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + // @Table @Selection + struct AncestorMetadata { + let recordName: String + let parentRecordName: String? + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let lastKnownServerRecord: CKRecord? + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { package init( recordPrimaryKey: String, recordType: String, @@ -93,13 +105,6 @@ self.userModificationDate = userModificationDate } - // @Selection @Table - struct AncestorMetadata { - let recordName: String - let parentRecordName: String? - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) - let lastKnownServerRecord: CKRecord? - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 93f28061..e2dacc02 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -163,22 +163,22 @@ private func rootServerRecord( With { SyncMetadata .where { $0.recordName.eq(recordName) } - .select { SyncMetadata.AncestorMetadata.Columns($0) } + .select { AncestorMetadata.Columns($0) } .union( all: true, SyncMetadata - .select { SyncMetadata.AncestorMetadata.Columns($0) } - .join(SyncMetadata.AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) } + .select { AncestorMetadata.Columns($0) } + .join(AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) } ) } query: { - SyncMetadata.AncestorMetadata + AncestorMetadata .select(\.lastKnownServerRecord) .where { $0.parentRecordName.is(nil) } } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata.AncestorMetadata.Columns { +extension AncestorMetadata.Columns { fileprivate init(_ metadata: SyncMetadata.TableColumns) { self.init( recordName: metadata.recordName, diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift index b2d23469..a56e54b2 100644 --- a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift @@ -1,11 +1,20 @@ import StructuredQueriesCore extension UnsyncedRecordID { - public struct TableColumns: StructuredQueriesCore.TableDefinition { + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { public typealias QueryValue = UnsyncedRecordID - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let zoneName = StructuredQueriesCore.TableColumn("zoneName", keyPath: \QueryValue.zoneName) - public let ownerName = StructuredQueriesCore.TableColumn("ownerName", keyPath: \QueryValue.ownerName) + public let recordName = StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + public let zoneName = StructuredQueriesCore.TableColumn( + "zoneName", + keyPath: \QueryValue.zoneName + ) + public let ownerName = StructuredQueriesCore.TableColumn( + "ownerName", + keyPath: \QueryValue.ownerName + ) public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { [QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName] } @@ -18,10 +27,14 @@ extension UnsyncedRecordID { } } -extension UnsyncedRecordID: StructuredQueriesCore.Table { - public static let columns = TableColumns() - public static let tableName = "sqlitedata_icloud_unsyncedRecordIDs" - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { +nonisolated extension UnsyncedRecordID: StructuredQueriesCore.Table { + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "sqlitedata_icloud_unsyncedRecordIDs" + } + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { let recordName = try decoder.decode(String.self) let zoneName = try decoder.decode(String.self) let ownerName = try decoder.decode(String.self) diff --git a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift index 4049d554..885b1e0e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift @@ -9,7 +9,7 @@ import Testing extension BaseCloudKitTests { @MainActor - final class AccountLifecycleTests: BaseCloudKitTests, Sendable { + final class AccountLifecycleTests: BaseCloudKitTests, @unchecked Sendable { @Test func signOutClearsUserDatabaseAndMetadatabase() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index de362b14..ed31188e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -180,7 +180,7 @@ extension BaseCloudKitTests { try RemindersList.find(1).delete().execute(db) } } - let modifications = try await withDependencies { + let modifications = try withDependencies { $0.date.now.addTimeInterval(2) } operation: { let reminderRecord = CKRecord( From 49f4dace61e4f8e8c9895fd24a9db71e154333cb Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 12 Aug 2025 10:41:31 -0700 Subject: [PATCH 479/581] Add Tagged support to SharingGRDB --- Package.resolved | 30 +++--- Package.swift | 24 ++++- Package@swift-6.0.swift | 114 ++++++++++++++++++++ Sources/SharingGRDBCore/Traits/Tagged.swift | 14 +++ 4 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 Package@swift-6.0.swift create mode 100644 Sources/SharingGRDBCore/Traits/Tagged.swift diff --git a/Package.resolved b/Package.resolved index 731ae517..afc19c60 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "304d33f17363a8d91fc9762f429327f95ab8d8ea5577c2120d53772b7ac70814", + "originHash" : "493bf6e940098a804cf8989b9f72881f75a5c49199e8c67acd3bcf701cf32b20", "pins" : [ { "identity" : "combine-schedulers", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", - "version" : "1.2.0" + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", - "version" : "1.9.2" + "revision" : "eefcdaa88d2e2fd82e3405dfb6eb45872011a0b5", + "version" : "1.9.3" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", - "version" : "1.6.0" + "revision" : "7d3509c7f4de78ad3eb3d804e036fb62e3585141", + "version" : "2.0.5" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9", - "version" : "2.5.2" + "revision" : "bddb52233714512f63e0dfa8cd0ee8203103f3b1", + "version" : "2.7.1" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "37230a37e83f1b7023be08e1b1a2603fcb1567fb", - "version" : "1.18.4" + "revision" : "d7e40607dcd6bc26543f5d9433103f06e0b28f8f", + "version" : "1.18.6" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "sendable-triggers", - "revision" : "c56fe85c2963d0beb22a9a8503a01989f4c0a866" + "revision" : "2468f4e34d909d11c053d773562c03ffea40a72e", + "version" : "0.12.1" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", - "version" : "1.5.2" + "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", + "version" : "1.6.1" } } ], diff --git a/Package.swift b/Package.swift index b8eca291..a80bcf4c 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 import PackageDescription @@ -28,13 +28,27 @@ let package = Package( targets: ["StructuredQueriesGRDBCore"] ), ], + traits: [ + .trait( + name: "SharingGRDBTagged", + description: "Introduce SharingGRDB conformances to the swift-tagged package." + ), + .default(enabledTraits: ["SharingGRDBTagged"]), + ], dependencies: [ .package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"), .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", branch: "sendable-triggers"), + .package( + url: "https://github.com/pointfreeco/swift-structured-queries", + from: "0.12.1", + traits: [ + .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SharingGRDBTagged"])), + ] + ), + .package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.10.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ @@ -52,6 +66,12 @@ let package = Package( .product(name: "GRDB", package: "GRDB.swift"), .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "Sharing", package: "swift-sharing"), + .product(name: "StructuredQueriesCore", package: "swift-structured-queries"), + .product( + name: "Tagged", + package: "swift-tagged", + condition: .when(traits: ["SharingGRDBTagged"]) + ), ] ), .testTarget( diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 00000000..ff040ff6 --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,114 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "sharing-grdb", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v7), + ], + products: [ + .library( + name: "SharingGRDB", + targets: ["SharingGRDB"] + ), + .library( + name: "SharingGRDBCore", + targets: ["SharingGRDBCore"] + ), + .library( + name: "StructuredQueriesGRDB", + targets: ["StructuredQueriesGRDB"] + ), + .library( + name: "StructuredQueriesGRDBCore", + targets: ["StructuredQueriesGRDBCore"] + ), + ], + dependencies: [ + .package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"), + .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), + .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.12.1"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), + ], + targets: [ + .target( + name: "SharingGRDB", + dependencies: [ + "SharingGRDBCore", + "StructuredQueriesGRDB", + ] + ), + .target( + name: "SharingGRDBCore", + dependencies: [ + "StructuredQueriesGRDBCore", + .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "Sharing", package: "swift-sharing"), + ] + ), + .testTarget( + name: "SharingGRDBTests", + dependencies: [ + "SharingGRDB", + .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), + .product(name: "StructuredQueries", package: "swift-structured-queries"), + ] + ), + .target( + name: "StructuredQueriesGRDBCore", + dependencies: [ + .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + .product(name: "StructuredQueriesCore", package: "swift-structured-queries"), + ] + ), + .target( + name: "StructuredQueriesGRDB", + dependencies: [ + "StructuredQueriesGRDBCore", + .product(name: "StructuredQueries", package: "swift-structured-queries"), + ] + ), + .testTarget( + name: "StructuredQueriesGRDBTests", + dependencies: [ + "StructuredQueriesGRDB", + .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "StructuredQueries", package: "swift-structured-queries"), + ] + ), + ], + swiftLanguageModes: [.v6] +) + +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("MemberImportVisibility"), + // .unsafeFlags([ + // "-Xfrontend", + // "-warn-long-function-bodies=50", + // "-Xfrontend", + // "-warn-long-expression-type-checking=50", + // ]) +] + +for index in package.targets.indices { + package.targets[index].swiftSettings = swiftSettings +} + +#if !os(Windows) + // Add the documentation compiler plugin if possible + package.dependencies.append( + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") + ) +#endif diff --git a/Sources/SharingGRDBCore/Traits/Tagged.swift b/Sources/SharingGRDBCore/Traits/Tagged.swift new file mode 100644 index 00000000..3dd6db46 --- /dev/null +++ b/Sources/SharingGRDBCore/Traits/Tagged.swift @@ -0,0 +1,14 @@ +#if SharingGRDBTagged + import Tagged + + extension Tagged: IdentifierStringConvertible where RawValue: IdentifierStringConvertible { + public init?(rawIdentifier: String) { + guard let rawValue = RawValue(rawIdentifier: rawIdentifier) else { return nil } + self.init(rawValue) + } + + public var rawIdentifier: String { + rawValue.rawIdentifier + } + } +#endif From d256118953bc05bdbd5fca8044bc92cd6f23fecd Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:06:50 -0500 Subject: [PATCH 480/581] Add uniqueness to tag titles. (#119) * Add uniqueness to tag titles. * wip * wip * wip * wip * wip * wip --- .../xcshareddata/swiftpm/Package.resolved | 6 +- .../swiftpm/Package.resolved.orig | 181 ++++++++++++++++++ Examples/Reminders/ReminderForm.swift | 4 +- Examples/Reminders/RemindersDetail.swift | 4 +- Examples/Reminders/RemindersLists.swift | 4 +- Examples/Reminders/Schema.swift | 62 +++--- Examples/Reminders/SearchReminders.swift | 2 +- Examples/Reminders/TagRow.swift | 2 +- Examples/Reminders/TagsForm.swift | 68 ++++++- .../SyncMetadata+MacroExpansion.swift | 2 +- 10 files changed, 289 insertions(+), 46 deletions(-) create mode 100644 Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5e9d9dc3..5c8d479d 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "05461ecd2d6ddd848b677fd572ee263adc87ab3aebd38d91f3f4c49ddaf3cdde", + "originHash" : "80ce8831f89d2da19d6c4e6f30a71328a79a080602e0d57e255665812e2823d7", "pins" : [ { "identity" : "combine-schedulers", @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "main", - "revision" : "2d9f1d94f5cbfbd37c5ab0b2500b385db95516a3" + "revision" : "2468f4e34d909d11c053d773562c03ffea40a72e", + "version" : "0.12.1" } }, { diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig new file mode 100644 index 00000000..23c487c1 --- /dev/null +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig @@ -0,0 +1,181 @@ +{ +<<<<<<< HEAD + "originHash" : "80ce8831f89d2da19d6c4e6f30a71328a79a080602e0d57e255665812e2823d7", +======= + "originHash" : "05461ecd2d6ddd848b677fd572ee263adc87ab3aebd38d91f3f4c49ddaf3cdde", +>>>>>>> origin/cloudkit + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "a5a1be26b4513dc7ec360eb56bc08a345bac6649", + "version" : "7.5.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "9810c8d6c2914de251e072312f01d3bf80071852", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", + "version" : "1.9.2" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21", + "version" : "2.3.1" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9", + "version" : "2.5.2" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "37230a37e83f1b7023be08e1b1a2603fcb1567fb", + "version" : "1.18.4" + } + }, + { + "identity" : "swift-structured-queries", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-structured-queries", + "state" : { + "revision" : "2468f4e34d909d11c053d773562c03ffea40a72e", + "version" : "0.12.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } + } + ], + "version" : 3 +} diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index e627a467..4fc55ec0 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -135,7 +135,7 @@ struct ReminderFormView: View { selectedTags = try await database.read { db in try Tag .order(by: \.title) - .join(ReminderTag.all) { $0.id.eq($1.tagID) } + .join(ReminderTag.all) { $0.primaryKey.eq($1.tagID) } .where { $1.reminderID.eq(reminderID) } .select { tag, _ in tag } .fetchAll(db) @@ -179,7 +179,7 @@ struct ReminderFormView: View { .execute(db) try ReminderTag.insert { selectedTags.map { tag in - ReminderTag.Draft(reminderID: reminderID, tagID: tag.id) + ReminderTag.Draft(reminderID: reminderID, tagID: tag.title) } } .execute(db) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 9565330a..ea9f2996 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -103,7 +103,7 @@ class RemindersDetailModel: HashableObject { case .flagged: reminder.isFlagged case .remindersList(let list): reminder.remindersListID.eq(list.id) case .scheduled: reminder.isScheduled - case .tags(let tags): tag.id.ifnull(UUID(0)).in(tags.map(\.id)) + case .tags(let tags): tag.title.ifnull("").in(tags.map(\.title)) case .today: reminder.isToday } } @@ -114,7 +114,7 @@ class RemindersDetailModel: HashableObject { remindersList: $3, isPastDue: $0.isPastDue, notes: $0.inlineNotes.substr(0, 200), - tags: #sql("\($2.jsonNames)") + tags: #sql("\($2.jsonTitles)") ) } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 18b416d2..02d88a3c 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -124,10 +124,10 @@ class RemindersListsModel { func deleteTags(indexSet: IndexSet) { withErrorReporting { - let tagIDs = indexSet.map { tags[$0].id } + let tagTitles = indexSet.map { tags[$0].title } try database.write { db in try Tag - .where { $0.id.in(tagIDs) } + .where { $0.title.in(tagTitles) } .delete() .execute(db) } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 2edf2d31..c5b89c88 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -44,8 +44,9 @@ extension Reminder.Draft: Identifiable {} @Table struct Tag: Hashable, Identifiable { - let id: UUID - var title = "" + @Column(primaryKey: true) + var title: String + var id: String { title } } enum Priority: Int, Codable, QueryBindable { @@ -64,7 +65,7 @@ extension Reminder { } static let withTags = group(by: \.id) .leftJoin(ReminderTag.all) { $0.id.eq($1.reminderID) } - .leftJoin(Tag.all) { $1.tagID.eq($2.id) } + .leftJoin(Tag.all) { $1.tagID.eq($2.primaryKey) } } extension Reminder.TableColumns { @@ -85,13 +86,13 @@ extension Reminder.TableColumns { } extension Tag { - static let withReminders = group(by: \.id) - .leftJoin(ReminderTag.all) { $0.id.eq($1.tagID) } + static let withReminders = group(by: \.title) + .leftJoin(ReminderTag.all) { $0.primaryKey.eq($1.tagID) } .leftJoin(Reminder.all) { $1.reminderID.eq($2.id) } } extension Tag.TableColumns { - var jsonNames: some QueryExpression<[String].JSONRepresentation> { + var jsonTitles: some QueryExpression<[String].JSONRepresentation> { self.title.jsonGroupArray(filter: self.title.isNot(nil)) } } @@ -183,8 +184,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "tags" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' + "title" TEXT COLLATE NOCASE PRIMARY KEY NOT NULL ) STRICT """ ) @@ -193,11 +193,8 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "remindersTags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "reminderID" TEXT NOT NULL, - "tagID" TEXT NOT NULL, - - FOREIGN KEY("reminderID") REFERENCES "reminders"("id") ON DELETE CASCADE, - FOREIGN KEY("tagID") REFERENCES "tags"("id") ON DELETE CASCADE + "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ ) @@ -246,7 +243,6 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") func seedSampleData() throws { let remindersListsIDs = (0...2).map { _ in UUID() } let remindersIDs = (0...10).map { _ in UUID() } - let tagsIDs = (0...6).map { _ in UUID() } try seed { RemindersList( id: remindersListsIDs[0], @@ -347,25 +343,25 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") remindersListID: remindersListsIDs[2], title: "Prepare for WWDC" ) - Tag(id: tagsIDs[0], title: "car") - Tag(id: tagsIDs[1], title: "kids") - Tag(id: tagsIDs[2], title: "someday") - Tag(id: tagsIDs[3], title: "optional") - Tag(id: tagsIDs[4], title: "social") - Tag(id: tagsIDs[5], title: "night") - Tag(id: tagsIDs[6], title: "adulting") - ReminderTag.Draft(reminderID: remindersIDs[0], tagID: tagsIDs[2]) - ReminderTag.Draft(reminderID: remindersIDs[0], tagID: tagsIDs[3]) - ReminderTag.Draft(reminderID: remindersIDs[0], tagID: tagsIDs[6]) - ReminderTag.Draft(reminderID: remindersIDs[1], tagID: tagsIDs[2]) - ReminderTag.Draft(reminderID: remindersIDs[1], tagID: tagsIDs[3]) - ReminderTag.Draft(reminderID: remindersIDs[2], tagID: tagsIDs[6]) - ReminderTag.Draft(reminderID: remindersIDs[3], tagID: tagsIDs[0]) - ReminderTag.Draft(reminderID: remindersIDs[3], tagID: tagsIDs[1]) - ReminderTag.Draft(reminderID: remindersIDs[4], tagID: tagsIDs[4]) - ReminderTag.Draft(reminderID: remindersIDs[3], tagID: tagsIDs[4]) - ReminderTag.Draft(reminderID: remindersIDs[10], tagID: tagsIDs[4]) - ReminderTag.Draft(reminderID: remindersIDs[4], tagID: tagsIDs[5]) + Tag(title: "car") + Tag(title: "kids") + Tag(title: "someday") + Tag(title: "optional") + Tag(title: "social") + Tag(title: "night") + Tag(title: "adulting") + ReminderTag.Draft(reminderID: remindersIDs[0], tagID: "someday") + ReminderTag.Draft(reminderID: remindersIDs[0], tagID: "optional") + ReminderTag.Draft(reminderID: remindersIDs[0], tagID: "adulting") + ReminderTag.Draft(reminderID: remindersIDs[1], tagID: "someday") + ReminderTag.Draft(reminderID: remindersIDs[1], tagID: "optional") + ReminderTag.Draft(reminderID: remindersIDs[2], tagID: "adulting") + ReminderTag.Draft(reminderID: remindersIDs[3], tagID: "car") + ReminderTag.Draft(reminderID: remindersIDs[3], tagID: "kids") + ReminderTag.Draft(reminderID: remindersIDs[4], tagID: "social") + ReminderTag.Draft(reminderID: remindersIDs[3], tagID: "social") + ReminderTag.Draft(reminderID: remindersIDs[10], tagID: "social") + ReminderTag.Draft(reminderID: remindersIDs[4], tagID: "night") } } } diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 0cc0ed5a..1c73ba6b 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -67,7 +67,7 @@ class SearchRemindersModel { notes: $0.inlineNotes, reminder: $0, remindersList: $3, - tags: #sql("\($2.jsonNames)") + tags: #sql("\($2.jsonTitles)") ) }, animation: .default diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index ffe38591..42b126f5 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -34,7 +34,7 @@ struct TagRow: View { #Preview { NavigationStack { List { - TagRow(tag: Tag(id: UUID(1), title: "optional")) + TagRow(tag: Tag(title: "optional")) } } } diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 0c576c16..3545a977 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -4,12 +4,21 @@ import SwiftUI struct TagsView: View { @Fetch(Tags()) var tags = Tags.Value() @Binding var selectedTags: [Tag] + @State var editingTag: Tag.Draft? + @State var tagTitle = "" + @Dependency(\.defaultDatabase) var database @Environment(\.dismiss) var dismiss var body: some View { Form { let selectedTagIDs = Set(selectedTags.map(\.id)) + Section { + Button("New tag") { + tagTitle = "" + editingTag = Tag.Draft() + } + } if !tags.top.isEmpty { Section { ForEach(tags.top, id: \.id) { tag in @@ -18,6 +27,14 @@ struct TagsView: View { selectedTags: $selectedTags, tag: tag ) + .swipeActions { + Button("Delete", role: .destructive) { + deleteButtonTapped(tag: tag) + } + Button("Edit") { + editButtonTapped(tag: tag) + } + } } } header: { Text("Top tags") @@ -31,10 +48,26 @@ struct TagsView: View { selectedTags: $selectedTags, tag: tag ) + .swipeActions { + Button("Delete", role: .destructive) { + deleteButtonTapped(tag: tag) + } + Button("Edit") { + editButtonTapped(tag: tag) + } + } } } } } + .alert(item: $editingTag) { item in + Text(item.title == nil ? "New tag" : "Edit tag") + } actions: { item in + TextField("Tag name", text: $tagTitle) + Button("Save") { + saveButtonTapped() + } + } .toolbar { ToolbarItem { Button("Done") { dismiss() } @@ -43,6 +76,39 @@ struct TagsView: View { .navigationTitle(Text("Tags")) } + func deleteButtonTapped(tag: Tag) { + withErrorReporting { + try database.write { db in + try Tag.find(tag.title).delete().execute(db) + } + } + } + + func editButtonTapped(tag: Tag) { + tagTitle = tag.title + editingTag = Tag.Draft(tag) + } + + func saveButtonTapped() { + defer { tagTitle = "" } + let tag = Tag(title: tagTitle) + withErrorReporting { + try database.write { db in + if let existingTagTitle = editingTag?.title { + selectedTags.removeAll(where: { $0.title == existingTagTitle }) + try Tag + .update { $0.title = tagTitle } + .where { $0.title.eq(existingTagTitle) } + .execute(db) + } else { + try Tag.insert(or: .ignore) { tag } + .execute(db) + } + } + selectedTags.append(tag) + } + } + struct Tags: FetchKeyRequest { func fetch(_ db: Database) throws -> Value { let top = @@ -56,7 +122,7 @@ struct TagsView: View { let rest = try Tag - .where { !$0.id.in(top.map(\.id)) } + .where { !$0.title.in(top.map(\.title)) } .order(by: \.title) .fetchAll(db) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 3621ee08..d6928a25 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -36,7 +36,7 @@ public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< QueryValue, CKRecord?.SystemFieldsRepresentation >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let _lastKnownServerRecordAllFields = StructuredQueriesCore.TableColumn< + package let _lastKnownServerRecordAllFields = StructuredQueriesCore.TableColumn< QueryValue, CKRecord?.AllFieldsRepresentation >("_lastKnownServerRecordAllFields", keyPath: \QueryValue._lastKnownServerRecordAllFields) public let share = StructuredQueriesCore.TableColumn< From d907238cb9f3609d4b2f16ee0f7fe68fe66456f0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 12 Aug 2025 17:12:46 -0500 Subject: [PATCH 481/581] Improve un-sharing records. --- .../CloudKit/CloudKitSharing.swift | 40 +++++++++++-------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 3 +- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index f5d3736e..ea6acc15 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -107,23 +107,27 @@ extension SyncEngine { recordID: CKRecord.ID(recordName: metadata.recordName, zoneID: defaultZone.zoneID) ) - // TODO: Catch unknownItem error from `.record(for:)` and go through `else` branch, otherwise rethrow - - let sharedRecord: CKShare - if let shareRecordID = rootRecord.share?.recordID, - let existingShare = try await container.database(for: rootRecord.recordID) - .record(for: shareRecordID) as? CKShare - { - sharedRecord = existingShare - } else { - sharedRecord = CKShare( - rootRecord: rootRecord, - shareID: CKRecord.ID( - recordName: UUID().uuidString, - zoneID: rootRecord.recordID.zoneID - ) + var existingShare: CKShare? { + get async throws { + guard let shareRecordID = rootRecord.share?.recordID + else { return nil } + do { + return try await container.database(for: rootRecord.recordID) + .record(for: shareRecordID) as? CKShare + } catch let error as CKError where error.code == .unknownItem { + reportIssue("This would have been a problem before") + return nil + } + } + } + + let sharedRecord = try await existingShare ?? CKShare( + rootRecord: rootRecord, + shareID: CKRecord.ID( + recordName: UUID().uuidString, + zoneID: rootRecord.recordID.zoneID ) - } + ) configure(sharedRecord) // TODO: We are getting an "client oplock error updating record" error in the logs when @@ -219,7 +223,9 @@ extension SyncEngine { public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { @Dependency(\.defaultSyncEngine) var syncEngine - // TODO: eagerly clear out share data + withErrorReporting { + try syncEngine.deleteShare(recordID: share.recordID) + } didStopSharing() } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 790b4f98..aa6f9d9e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1137,13 +1137,14 @@ } } - private func deleteShare(recordID: CKRecord.ID) throws { + func deleteShare(recordID: CKRecord.ID) throws { try userDatabase.write { db in let shareAndRecordName = try SyncMetadata .where(\.isShared) .select { ($0.share, $0.recordName) } .fetchAll(db) + // TODO: Write test that we never accidentally delete a new share from a delete event of an old share .first(where: { share, _ in share?.recordID == recordID }) ?? nil guard let (_, recordName) = shareAndRecordName else { return } From e751e8d93bb8bce7232502c9c13a4aca744ced24 Mon Sep 17 00:00:00 2001 From: David Moeller Date: Wed, 13 Aug 2025 16:16:23 +0200 Subject: [PATCH 482/581] Add Available Permissions (#123) Co-authored-by: David Moeller --- Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index ea6acc15..ed301c64 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -155,19 +155,22 @@ extension SyncEngine { @available(iOS 17, macOS 14, tvOS 17, *) public struct CloudSharingView: UIViewControllerRepresentable { let sharedRecord: SharedRecord + let availablePermissions: UICloudSharingController.PermissionOptions let didFinish: (Result) -> Void let didStopSharing: () -> Void - public init(sharedRecord: SharedRecord) { - self.init(sharedRecord: sharedRecord, didFinish: { _ in }, didStopSharing: {}) + public init(sharedRecord: SharedRecord, availablePermissions: UICloudSharingController.PermissionOptions = []) { + self.init(sharedRecord: sharedRecord, availablePermissions: availablePermissions, didFinish: { _ in }, didStopSharing: {}) } public init( sharedRecord: SharedRecord, + availablePermissions: UICloudSharingController.PermissionOptions = [], didFinish: @escaping (Result) -> Void, didStopSharing: @escaping () -> Void ) { self.sharedRecord = sharedRecord self.didFinish = didFinish self.didStopSharing = didStopSharing + self.availablePermissions = availablePermissions } public func makeCoordinator() -> CloudSharingDelegate { @@ -184,6 +187,7 @@ extension SyncEngine { container: sharedRecord.container.rawValue ) controller.delegate = context.coordinator + controller.availablePermissions = availablePermissions return controller } From 4ed79c4bc231ff8658abfc8c02c520f2b18dea0e Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:15:52 -0500 Subject: [PATCH 483/581] Make sync engine play nicely with tests (#124) * wip * control dates internally * wip * wip * add a test * wip --- Examples/Examples.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- Examples/Reminders/RemindersApp.swift | 12 +- Examples/Reminders/Schema.swift | 14 + Examples/Reminders/TagsForm.swift | 1 + Examples/RemindersTests/Internal.swift | 43 +- .../RemindersDetailsTests.swift | 2 + .../RemindersTests/RemindersListsTests.swift | 2 + .../RemindersTests/SearchRemindersTests.swift | 2 + .../CloudKit/Internal/DatetimeGenerator.swift | 26 + .../CloudKit}/Internal/IsolatedWeakVar.swift | 0 .../Internal/MockCloudContainer.swift | 101 +++ .../CloudKit/Internal/MockCloudDatabase.swift | 283 ++++++++ .../CloudKit/Internal/MockSyncEngine.swift | 268 ++++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 63 +- .../CloudKitTests/AssetsTests.swift | 2 +- .../CloudKitTests/CloudKitTests.swift | 48 +- .../FetchRecordZoneChangesTests.swift | 8 +- .../ForeignKeyConstraintTests.swift | 14 +- .../CloudKitTests/MergeConflictTests.swift | 14 +- .../CloudKitTests/MetadataTests.swift | 11 +- .../MockCloudDatabaseTests.swift | 15 + .../CloudKitTests/RecordTypeTests.swift | 14 +- .../ReferenceViolationTests.swift | 18 +- .../CloudKitTests/SchemaChangeTests.swift | 8 +- .../CloudKitTests/SharingTests.swift | 6 +- .../CloudKitTests/TriggerTests.swift | 6 +- .../CloudKitTests/UserlandTests.swift | 31 + .../Internal/BaseCloudKitTests.swift | 6 +- .../Internal/CloudKitTestHelpers.swift | 630 ------------------ Tests/SharingGRDBTests/Internal/Schema.swift | 16 +- 31 files changed, 902 insertions(+), 770 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift rename {Tests/SharingGRDBTests => Sources/SharingGRDBCore/CloudKit}/Internal/IsolatedWeakVar.swift (100%) create mode 100644 Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index b6566760..bc3e7f5b 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -1072,6 +1072,8 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; }; name = Debug; @@ -1128,6 +1130,8 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; @@ -1435,7 +1439,7 @@ /* Begin XCLocalSwiftPackageReference section */ CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */ = { isa = XCLocalSwiftPackageReference; - relativePath = ".."; + relativePath = ..; }; /* End XCLocalSwiftPackageReference section */ diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5c8d479d..bcf55766 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "80ce8831f89d2da19d6c4e6f30a71328a79a080602e0d57e255665812e2823d7", + "originHash" : "b79958a17ad17f026cb1bfeb29707111c20562741e2ca36a9cc6564f6dcec338", "pins" : [ { "identity" : "combine-schedulers", diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 115e51f3..b1f3690f 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,4 +1,6 @@ import CloudKit +import Combine +import Dependencies import SharingGRDB import SwiftUI import UIKit @@ -12,15 +14,7 @@ struct RemindersApp: App { init() { if context == .live { try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - $0.defaultSyncEngine = try SyncEngine( - for: $0.defaultDatabase, - tables: RemindersList.self, - RemindersListAsset.self, - Reminder.self, - Tag.self, - ReminderTag.self - ) + try $0.bootstrapDatabase() } } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index c5b89c88..973b3295 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -104,6 +104,20 @@ struct ReminderTag: Hashable, Identifiable { var tagID: Tag.ID } +extension DependencyValues { + mutating func bootstrapDatabase() throws { + defaultDatabase = try Reminders.appDatabase() + defaultSyncEngine = try SyncEngine( + for: defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self + ) + } +} + func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 3545a977..5bfb7c29 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -1,5 +1,6 @@ import SharingGRDB import SwiftUI +import SwiftUINavigation struct TagsView: View { @Fetch(Tags()) var tags = Tags.Value() diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index 82a02504..944177d3 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -1,5 +1,8 @@ +import Dependencies +import DependenciesTestSupport import Foundation import SharingGRDB +import SnapshotTesting import SwiftUI import Testing @@ -8,7 +11,7 @@ import Testing @Suite( .dependencies { $0.date.now = baseDate - $0.defaultDatabase = try Reminders.appDatabase() + try $0.bootstrapDatabase() try $0.defaultDatabase.write { try $0.seedTestData() } }, .snapshots(record: .failed) @@ -132,25 +135,25 @@ extension Database { remindersListID: UUID(2), title: "Prepare for WWDC" ) - Tag(id: UUID(0), title: "car") - Tag(id: UUID(1), title: "kids") - Tag(id: UUID(2), title: "someday") - Tag(id: UUID(3), title: "optional") - Tag(id: UUID(4), title: "social") - Tag(id: UUID(5), title: "night") - Tag(id: UUID(6), title: "adulting") - ReminderTag.Draft(reminderID: UUID(0), tagID: UUID(2)) - ReminderTag.Draft(reminderID: UUID(0), tagID: UUID(3)) - ReminderTag.Draft(reminderID: UUID(0), tagID: UUID(6)) - ReminderTag.Draft(reminderID: UUID(1), tagID: UUID(2)) - ReminderTag.Draft(reminderID: UUID(1), tagID: UUID(3)) - ReminderTag.Draft(reminderID: UUID(2), tagID: UUID(6)) - ReminderTag.Draft(reminderID: UUID(3), tagID: UUID(0)) - ReminderTag.Draft(reminderID: UUID(3), tagID: UUID(1)) - ReminderTag.Draft(reminderID: UUID(4), tagID: UUID(4)) - ReminderTag.Draft(reminderID: UUID(3), tagID: UUID(4)) - ReminderTag.Draft(reminderID: UUID(10), tagID: UUID(4)) - ReminderTag.Draft(reminderID: UUID(4), tagID: UUID(5)) + Tag(title: "car") + Tag(title: "kids") + Tag(title: "someday") + Tag(title: "optional") + Tag(title: "social") + Tag(title: "night") + Tag(title: "adulting") + ReminderTag.Draft(reminderID: UUID(0), tagID: "someday") + ReminderTag.Draft(reminderID: UUID(0), tagID: "optional") + ReminderTag.Draft(reminderID: UUID(0), tagID: "adulting") + ReminderTag.Draft(reminderID: UUID(1), tagID: "someday") + ReminderTag.Draft(reminderID: UUID(1), tagID: "optional") + ReminderTag.Draft(reminderID: UUID(2), tagID: "adulting") + ReminderTag.Draft(reminderID: UUID(3), tagID: "car") + ReminderTag.Draft(reminderID: UUID(3), tagID: "kids") + ReminderTag.Draft(reminderID: UUID(4), tagID: "social") + ReminderTag.Draft(reminderID: UUID(3), tagID: "social") + ReminderTag.Draft(reminderID: UUID(10), tagID: "social") + ReminderTag.Draft(reminderID: UUID(4), tagID: "night") } } } diff --git a/Examples/RemindersTests/RemindersDetailsTests.swift b/Examples/RemindersTests/RemindersDetailsTests.swift index 89529d88..96db1f7a 100644 --- a/Examples/RemindersTests/RemindersDetailsTests.swift +++ b/Examples/RemindersTests/RemindersDetailsTests.swift @@ -1,6 +1,8 @@ import Dependencies import DependenciesTestSupport +import GRDB import InlineSnapshotTesting +import SharingGRDB import SnapshotTestingCustomDump import Testing diff --git a/Examples/RemindersTests/RemindersListsTests.swift b/Examples/RemindersTests/RemindersListsTests.swift index 28eef9b3..4cf16a2f 100644 --- a/Examples/RemindersTests/RemindersListsTests.swift +++ b/Examples/RemindersTests/RemindersListsTests.swift @@ -1,4 +1,6 @@ +import GRDB import InlineSnapshotTesting +import SharingGRDB import SnapshotTestingCustomDump import Testing diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index de0c53c5..d996868f 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -1,6 +1,8 @@ import Dependencies import DependenciesTestSupport import InlineSnapshotTesting +import GRDB +import SharingGRDB import SnapshotTestingCustomDump import Testing diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift b/Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift new file mode 100644 index 00000000..94c36a11 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift @@ -0,0 +1,26 @@ +import Dependencies +import Foundation + +package struct DatetimeGenerator: DependencyKey, Sendable { + private var generate: @Sendable () -> Date + package var now: Date { + get { self.generate() } + set { self.generate = { newValue } } + } + package func callAsFunction() -> Date { + self.generate() + } + package static var liveValue: DatetimeGenerator { + Self { Date() } + } + package static var testValue: DatetimeGenerator { + Self { Date() } + } +} + +extension DependencyValues { + package var datetime: DatetimeGenerator { + get { self[DatetimeGenerator.self] } + set { self[DatetimeGenerator.self] = newValue } + } +} diff --git a/Tests/SharingGRDBTests/Internal/IsolatedWeakVar.swift b/Sources/SharingGRDBCore/CloudKit/Internal/IsolatedWeakVar.swift similarity index 100% rename from Tests/SharingGRDBTests/Internal/IsolatedWeakVar.swift rename to Sources/SharingGRDBCore/CloudKit/Internal/IsolatedWeakVar.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift new file mode 100644 index 00000000..56fe5bf9 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift @@ -0,0 +1,101 @@ +import CustomDump +import CloudKit + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { + package let _accountStatus: LockIsolated + package let containerIdentifier: String? + package let privateCloudDatabase: MockCloudDatabase + package let sharedCloudDatabase: MockCloudDatabase + + package init( + accountStatus: CKAccountStatus = .available, + containerIdentifier: String?, + privateCloudDatabase: MockCloudDatabase, + sharedCloudDatabase: MockCloudDatabase + ) { + self._accountStatus = LockIsolated(accountStatus) + self.containerIdentifier = containerIdentifier + self.privateCloudDatabase = privateCloudDatabase + self.sharedCloudDatabase = sharedCloudDatabase + } + + package func accountStatus() -> CKAccountStatus { + _accountStatus.withValue(\.self) + } + + package var rawValue: CKContainer { + fatalError("This should never be called in tests.") + } + + package func accountStatus() async throws -> CKAccountStatus { + _accountStatus.withValue { $0 } + } + + package func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata { + fatalError() + } + + package func accept(_ metadata: CKShare.Metadata) async throws -> CKShare { + fatalError() + } + + package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { + @Dependency(\.mockCloudContainers) var mockCloudContainers + return mockCloudContainers.withValue { storage in + let container: MockCloudContainer + if let existingContainer = storage[containerIdentifier] { + container = existingContainer + } else { + container = MockCloudContainer( + accountStatus: .available, + containerIdentifier: containerIdentifier, + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ) + container.privateCloudDatabase.set(container: container) + container.sharedCloudDatabase.set(container: container) + } + storage[containerIdentifier] = container + return container + } + } + + package static func == (lhs: MockCloudContainer, rhs: MockCloudContainer) -> Bool { + lhs === rhs + } + + package func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + + package var customDumpMirror: Mirror { + Mirror.init( + self, + children: [ + ("privateCloudDatabase", privateCloudDatabase), + ("sharedCloudDatabase", sharedCloudDatabase), + ], + displayStyle: .struct + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private enum MockCloudContainersKey: TestDependencyKey { + static var testValue: LockIsolated<[String: MockCloudContainer]> { + LockIsolated<[String: MockCloudContainer]>([:]) + } +} + +extension DependencyValues { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate var mockCloudContainers: LockIsolated<[String: MockCloudContainer]> { + get { + self[MockCloudContainersKey.self] + } + set { + self[MockCloudContainersKey.self] = newValue + } + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift new file mode 100644 index 00000000..1fb82e7b --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -0,0 +1,283 @@ +import CloudKit +import CustomDump +import IssueReporting + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockCloudDatabase: CloudDatabase { + package let storage = LockIsolated<[CKRecordZone.ID: [CKRecord.ID: CKRecord]]>([:]) + let assets = LockIsolated<[AssetID: Data]>([:]) + package let databaseScope: CKDatabase.Scope + let _container = IsolatedWeakVar() + + let dataManager = Dependency(\.dataManager) + + struct AssetID: Hashable { + let recordID: CKRecord.ID + let key: String + } + + package init(databaseScope: CKDatabase.Scope) { + self.databaseScope = databaseScope + } + + package func set(container: MockCloudContainer) { + _container.set(container) + } + + package var container: MockCloudContainer { + _container.value! + } + + package func record(for recordID: CKRecord.ID) throws -> CKRecord { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + guard let zone = storage[recordID.zoneID] + else { throw CKError(.zoneNotFound) } + guard let record = zone[recordID] + else { throw CKError(.unknownItem) } + guard let record = record.copy() as? CKRecord + else { fatalError("Could not copy CKRecord.") } + + try assets.withValue { assets in + for key in record.allKeys() { + guard let assetData = assets[AssetID(recordID: record.recordID, key: key)] + else { continue } + let url = URL(filePath: UUID().uuidString.lowercased()) + try dataManager.wrappedValue.save(assetData, to: url) + record[key] = CKAsset(fileURL: url) + } + } + + return record + } + + package func records( + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? + ) throws -> [CKRecord.ID: Result] { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + + var results: [CKRecord.ID: Result] = [:] + for id in ids { + results[id] = Result { try record(for: id) } + } + return results + } + + package func modifyRecords( + saving recordsToSave: [CKRecord] = [], + deleting recordIDsToDelete: [CKRecord.ID] = [], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged, + atomically: Bool = true + ) throws -> ( + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] + ) { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + + return storage.withValue { storage in + var saveResults: [CKRecord.ID: Result] = [:] + var deleteResults: [CKRecord.ID: Result] = [:] + + switch savePolicy { + case .ifServerRecordUnchanged: + for recordToSave in recordsToSave { + guard storage[recordToSave.recordID.zoneID] != nil + else { + saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) + continue + } + + let existingRecord = storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] + + func saveRecordToDatabase() { + let hasReferenceViolation = + recordToSave.parent.map { parent in + storage[parent.recordID.zoneID]?[parent.recordID] == nil + && !recordsToSave.contains { $0.recordID == parent.recordID } + } + ?? false + guard !hasReferenceViolation + else { + saveResults[recordToSave.recordID] = .failure(CKError(.referenceViolation)) + return + } + + guard let copy = recordToSave.copy() as? CKRecord + else { fatalError("Could not copy CKRecord.") } + copy._recordChangeTag = UUID().uuidString + assets.withValue { assets in + for key in copy.allKeys() { + guard let assetURL = (copy[key] as? CKAsset)?.fileURL + else { continue } + assets[AssetID(recordID: copy.recordID, key: key)] = try? dataManager.wrappedValue + .load(assetURL) + } + } + storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy + saveResults[recordToSave.recordID] = .success(copy) + } + + switch (existingRecord, recordToSave._recordChangeTag) { + case (.some(let existingRecord), .some(let recordToSaveChangeTag)): + // We are trying to save a record with a change tag that also already exists in the + // DB. If the tags match, we can save the record. Otherwise, we notify the sync engine + // that the server record has changed since it was last synced. + if existingRecord._recordChangeTag == recordToSaveChangeTag { + precondition(existingRecord._recordChangeTag != nil) + saveRecordToDatabase() + } else { + saveResults[recordToSave.recordID] = .failure( + CKError( + .serverRecordChanged, + userInfo: [ + CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, + CKRecordChangedErrorClientRecordKey: recordToSave.copy(), + ] + ) + ) + } + break + case (.some(let existingRecord), .none): + // We are trying to save a record that does not have a change tag yet also already + // exists in the DB. This means the user has created a new CKRecord from scratch, + // giving it a new identity, rather than leveraging an existing CKRecord. + reportIssue( + """ + A new identity was created for an existing 'CKRecord' \ + ('\(existingRecord.recordID.recordName)'). Rather than creating \ + 'CKRecord' from scratch for an existing record, use the database to fetch the \ + current record. + """ + ) + saveResults[recordToSave.recordID] = .failure( + CKError( + .serverRejectedRequest, + userInfo: [ + CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, + CKRecordChangedErrorClientRecordKey: recordToSave.copy(), + ] + ) + ) + case (.none, .some): + // We are trying to save a record with a change tag but it does not exist in the DB. + // This means the record was deleted by another device. + saveResults[recordToSave.recordID] = .failure(CKError(.unknownItem)) + case (.none, .none): + // We are trying to save a record with no change tag and no existing record in the DB. + // This means it's a brand new record. + saveRecordToDatabase() + } + } + case .allKeys, .changedKeys: + fatalError() + @unknown default: + fatalError() + } + for recordIDToDelete in recordIDsToDelete { + guard storage[recordIDToDelete.zoneID] != nil + else { + deleteResults[recordIDToDelete] = .failure(CKError(.zoneNotFound)) + continue + } + let hasReferenceViolation = !Set( + storage[recordIDToDelete.zoneID]?.values + .compactMap { $0.parent?.recordID == recordIDToDelete ? $0.recordID : nil } + ?? [] + ) + .subtracting(recordIDsToDelete) + .isEmpty + + guard !hasReferenceViolation + else { + deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) + continue + } + storage[recordIDToDelete.zoneID]?[recordIDToDelete] = nil + deleteResults[recordIDToDelete] = .success(()) + } + + return (saveResults: saveResults, deleteResults: deleteResults) + } + } + + package func modifyRecordZones( + saving recordZonesToSave: [CKRecordZone] = [], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] + ) throws -> ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) { + let accountStatus = container.accountStatus() + guard accountStatus == .available + else { throw ckError(forAccountStatus: accountStatus) } + + return storage.withValue { storage in + var saveResults: [CKRecordZone.ID: Result] = [:] + var deleteResults: [CKRecordZone.ID: Result] = [:] + + for recordZoneToSave in recordZonesToSave { + storage[recordZoneToSave.zoneID] = storage[recordZoneToSave.zoneID] ?? [:] + saveResults[recordZoneToSave.zoneID] = .success(recordZoneToSave) + } + + for recordZoneIDsToDelete in recordZoneIDsToDelete { + guard storage[recordZoneIDsToDelete] != nil + else { + deleteResults[recordZoneIDsToDelete] = .failure(CKError(.zoneNotFound)) + continue + } + storage[recordZoneIDsToDelete] = nil + deleteResults[recordZoneIDsToDelete] = .success(()) + } + + return (saveResults: saveResults, deleteResults: deleteResults) + } + } + + package nonisolated static func == (lhs: MockCloudDatabase, rhs: MockCloudDatabase) -> Bool { + lhs === rhs + } + + package nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension MockCloudDatabase: CustomDumpReflectable { + package var customDumpMirror: Mirror { + Mirror( + self, + children: [ + "databaseScope": databaseScope, + "storage": storage + .value + .flatMap { _, value in value.values } + .sorted { + ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) + }, + ], + displayStyle: .struct + ) + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError { + switch accountStatus { + case .couldNotDetermine, .restricted, .noAccount: + return CKError(.notAuthenticated) + case .temporarilyUnavailable: + return CKError(.accountTemporarilyUnavailable) + case .available: + fatalError() + @unknown default: + fatalError() + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift new file mode 100644 index 00000000..3bd4507a --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift @@ -0,0 +1,268 @@ +import CloudKit +import CustomDump +import OrderedCollections + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockSyncEngine: SyncEngineProtocol { + package let database: MockCloudDatabase + package let delegate: any SyncEngineDelegate + private let _state: LockIsolated + private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) + private let _acceptedShareMetadata = LockIsolated>([]) + + package init( + database: MockCloudDatabase, + delegate: any SyncEngineDelegate, + state: MockSyncEngineState + ) { + self.database = database + self.delegate = delegate + self._state = LockIsolated(state) + } + + package var scope: CKDatabase.Scope { + database.databaseScope + } + + package var state: MockSyncEngineState { + _state.withValue(\.self) + } + + package func acceptShare(metadata: ShareMetadata) { + _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } + } + + package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { + // TODO: do something here + } + + package func recordZoneChangeBatch( + pendingChanges: [CKSyncEngine.PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + var recordsToSave: [CKRecord] = [] + var recordIDsSkipped: [CKRecord.ID] = [] + var recordIDsToDelete: [CKRecord.ID] = [] + for pendingChange in pendingChanges { + switch pendingChange { + case .saveRecord(let recordID): + guard let record = await recordProvider(recordID) + else { + recordIDsSkipped.append(recordID) + continue + } + recordsToSave.append(record) + case .deleteRecord(let recordID): + recordIDsToDelete.append(recordID) + @unknown default: + fatalError() + } + } + + state.remove(pendingRecordZoneChanges: recordIDsSkipped.map { .saveRecord($0) }) + + return CKSyncEngine.RecordZoneChangeBatch( + recordsToSave: recordsToSave, + recordIDsToDelete: recordIDsToDelete + ) + } + + package func assertFetchChangesScopes( + _ scopes: [CKSyncEngine.FetchChangesOptions.Scope], + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _fetchChangesScopes.withValue { + expectNoDifference( + scopes, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package func assertAcceptedShareMetadata( + _ sharedMetadata: Set, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _acceptedShareMetadata.withValue { + expectNoDifference( + sharedMetadata, + $0, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package func cancelOperations() async { + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +package final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectable { + private let _pendingRecordZoneChanges = LockIsolated< + OrderedSet + >([] + ) + private let _pendingDatabaseChanges = LockIsolated< + OrderedSet + >([]) + private let fileID: StaticString + private let filePath: StaticString + private let line: UInt + private let column: UInt + + package init( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } + + package func assertPendingRecordZoneChanges( + _ changes: OrderedSet, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _pendingRecordZoneChanges.withValue { + expectNoDifference( + Set(changes), + Set($0), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package func assertPendingDatabaseChanges( + _ changes: OrderedSet, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + _pendingDatabaseChanges.withValue { + expectNoDifference( + Set(changes), + Set($0), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + $0.removeAll() + } + } + + package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { + _pendingRecordZoneChanges.withValue { Array($0) } + } + + package var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { + _pendingDatabaseChanges.withValue { Array($0) } + } + + package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.append(contentsOf: pendingRecordZoneChanges) + } + } + + package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + self._pendingRecordZoneChanges.withValue { + $0.subtract(pendingRecordZoneChanges) + } + } + + package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.append(contentsOf: pendingDatabaseChanges) + } + } + + package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + self._pendingDatabaseChanges.withValue { + $0.subtract(pendingDatabaseChanges) + } + } + + package var customDumpMirror: Mirror { + return Mirror( + self, + children: [ + ( + "pendingRecordZoneChanges", + _pendingRecordZoneChanges.withValue(\.self) + .sorted(by: comparePendingRecordZoneChange) + as Any + ), + ( + "pendingDatabaseChanges", + _pendingDatabaseChanges.withValue(\.self) + .sorted(by: comparePendingDatabaseChange) as Any + ), + ], + displayStyle: .struct + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private func comparePendingRecordZoneChange( + _ lhs: CKSyncEngine.PendingRecordZoneChange, + _ rhs: CKSyncEngine.PendingRecordZoneChange +) -> Bool { + switch (lhs, rhs) { + case (.saveRecord(let lhs), .saveRecord(let rhs)), + (.deleteRecord(let lhs), .deleteRecord(let rhs)): + lhs.recordName < rhs.recordName + case (.deleteRecord, .saveRecord): + true + case (.saveRecord, .deleteRecord): + false + default: + false + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private func comparePendingDatabaseChange( + _ lhs: CKSyncEngine.PendingDatabaseChange, + _ rhs: CKSyncEngine.PendingDatabaseChange +) -> Bool { + switch (lhs, rhs) { + case (.saveZone(let lhs), .saveZone(let rhs)): + lhs.zoneID.zoneName < rhs.zoneID.zoneName + case (.deleteZone(let lhs), .deleteZone(let rhs)): + lhs.zoneName < rhs.zoneName + case (.deleteZone, .saveZone): + true + case (.saveZone, .deleteZone): + false + default: + false + } +} diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index aa6f9d9e..4ae48eab 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -2,6 +2,7 @@ import CloudKit import ConcurrencyExtras import CustomDump + import Dependencies import OrderedCollections import OSLog import StructuredQueriesCore @@ -32,7 +33,7 @@ privateTables: repeat (each T2).Type, containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), - logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit") + logger: Logger = isTesting ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") ) throws where repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, @@ -41,6 +42,53 @@ let containerIdentifier = containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier + var allTables: [any PrimaryKeyedTable.Type] = [] + var allPrivateTables: [any PrimaryKeyedTable.Type] = [] + for table in repeat each tables { + allTables.append(table) + } + for privateTable in repeat each privateTables { + allPrivateTables.append(privateTable) + } + let userDatabase = UserDatabase(database: database) + + guard !isTesting + else { + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + try self.init( + container: MockCloudContainer( + containerIdentifier: containerIdentifier ?? "co.pointfree.sqlitedata-icloud.testing", + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase + ), + defaultZone: defaultZone, + defaultSyncEngines: { _, syncEngine in + ( + private: MockSyncEngine( + database: privateDatabase, + delegate: syncEngine, + state: MockSyncEngineState() + ), + shared: MockSyncEngine( + database: sharedDatabase, + delegate: syncEngine, + state: MockSyncEngineState() + ) + ) + }, + userDatabase: userDatabase, + logger: logger, + tables: allTables, + privateTables: allPrivateTables + ) + _ = try setUpSyncEngine( + userDatabase: userDatabase, + metadatabase: metadatabase + ) + return + } + guard let containerIdentifier else { throw SchemaError( reason: .noCloudKitContainer, @@ -52,16 +100,6 @@ } let container = CKContainer(identifier: containerIdentifier) - var allTables: [any PrimaryKeyedTable.Type] = [] - var allPrivateTables: [any PrimaryKeyedTable.Type] = [] - for table in repeat each tables { - allTables.append(table) - } - for privateTable in repeat each privateTables { - allPrivateTables.append(privateTable) - } - - let userDatabase = UserDatabase(database: database) try self.init( container: container, defaultZone: defaultZone, @@ -1144,7 +1182,6 @@ .where(\.isShared) .select { ($0.share, $0.recordName) } .fetchAll(db) - // TODO: Write test that we never accidentally delete a new share from a delete event of an old share .first(where: { share, _ in share?.recordID == recordID }) ?? nil guard let (_, recordName) = shareAndRecordName else { return } @@ -1395,7 +1432,7 @@ fileprivate static var datetime: Self { Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in - @Dependency(\.date.now) var now + @Dependency(\.datetime.now) var now return now.formatted( .iso8601 .year().month().day() diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index 2a6b3d2d..0fd6da2c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -70,7 +70,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersListAsset diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 4fec6e12..84099044 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -192,21 +192,13 @@ extension BaseCloudKitTests { tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE ) STRICT """, tableInfo: [ [0]: TableInfo( defaultValue: nil, isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, name: "title", notNull: true, type: "TEXT" @@ -219,7 +211,7 @@ extension BaseCloudKitTests { CREATE TABLE "reminderTags" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """, tableInfo: [ @@ -242,7 +234,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "tagID", notNull: true, - type: "INTEGER" + type: "TEXT" ) ] ), @@ -619,7 +611,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try RemindersList @@ -770,7 +762,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) @@ -952,8 +944,8 @@ extension BaseCloudKitTests { @Test func cascadingDeletionOrder() async throws { try await userDatabase.userWrite { db in try db.seed { - Tag(id: 1, title: "") - Tag(id: 2, title: "") + Tag(title: "fun") + Tag(title: "weekend") } } for _ in 1...100 { @@ -965,14 +957,14 @@ extension BaseCloudKitTests { Reminder(id: 2, title: "", remindersListID: 1) Reminder(id: 3, title: "", remindersListID: 1) Reminder(id: 4, title: "", remindersListID: 1) - ReminderTag(id: 1, reminderID: 1, tagID: 1) - ReminderTag(id: 2, reminderID: 2, tagID: 1) - ReminderTag(id: 3, reminderID: 3, tagID: 1) - ReminderTag(id: 4, reminderID: 4, tagID: 1) - ReminderTag(id: 5, reminderID: 1, tagID: 2) - ReminderTag(id: 6, reminderID: 2, tagID: 2) - ReminderTag(id: 7, reminderID: 3, tagID: 2) - ReminderTag(id: 8, reminderID: 4, tagID: 2) + ReminderTag(id: 1, reminderID: 1, tagID: "fun") + ReminderTag(id: 2, reminderID: 2, tagID: "fun") + ReminderTag(id: 3, reminderID: 3, tagID: "fun") + ReminderTag(id: 4, reminderID: 4, tagID: "fun") + ReminderTag(id: 5, reminderID: 1, tagID: "weekend") + ReminderTag(id: 6, reminderID: 2, tagID: "weekend") + ReminderTag(id: 7, reminderID: 3, tagID: "weekend") + ReminderTag(id: 8, reminderID: 4, tagID: "weekend") } } @@ -990,20 +982,18 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(fun:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, - id: 1, - title: "" + title: "fun" ), [1]: CKRecord( - recordID: CKRecord.ID(2:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(weekend:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, - id: 2, - title: "" + title: "weekend" ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index f3483154..c3051a28 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -61,7 +61,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) @@ -116,7 +116,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { let reminderRecord = try syncEngine.private.database .record(for: Reminder.recordID(for: 1)) @@ -245,7 +245,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) @@ -366,7 +366,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index f3d79db6..692c1d7b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -98,7 +98,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) @@ -388,7 +388,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) @@ -508,7 +508,7 @@ extension BaseCloudKitTests { } let modifications = try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { let reminderRecord = try syncEngine.private.database.record( for: Reminder.recordID(for: 1) @@ -591,7 +591,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) let modifications = try withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { let reminderRecord = try syncEngine.private.database .record(for: Reminder.recordID(for: 1)) @@ -604,7 +604,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1) @@ -674,7 +674,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) let modifications = try withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { let reminderRecord = try syncEngine.private.database .record(for: Reminder.recordID(for: 1)) @@ -687,7 +687,7 @@ extension BaseCloudKitTests { } try await withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.remindersListID = 3 }.execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 4e426e37..3416b803 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -68,7 +68,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(30) + $0.datetime.now = now.addingTimeInterval(30) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted = true }.execute(db) @@ -222,7 +222,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(60) + $0.datetime.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted = true }.execute(db) @@ -335,7 +335,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(60) + $0.datetime.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted = true }.execute(db) @@ -404,7 +404,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(30) + $0.datetime.now = now.addingTimeInterval(30) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) @@ -480,7 +480,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(60) + $0.datetime.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) @@ -549,7 +549,7 @@ extension BaseCloudKitTests { }() try await withDependencies { - $0.date.now = now.addingTimeInterval(60) + $0.datetime.now = now.addingTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) @@ -626,7 +626,7 @@ extension BaseCloudKitTests { ) try withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try userDatabase.userWrite { db in try Reminder.find(1).update { $0.priority = 3 }.execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 906a7479..9470bf6f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -73,7 +73,7 @@ extension BaseCloudKitTests { } try withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { _ = try { try userDatabase.userWrite { db in @@ -139,8 +139,8 @@ extension BaseCloudKitTests { try db.seed { RemindersList(id: 1, title: "Personal") Reminder(id: 1, title: "Groceries", remindersListID: 1) - Tag(id: 1, title: "weekend") - ReminderTag(id: 1, reminderID: 1, tagID: 1) + Tag(title: "weekend") + ReminderTag(id: 1, reminderID: 1, tagID: "weekend") } } @@ -158,7 +158,7 @@ extension BaseCloudKitTests { share: nil, id: 1, reminderID: 1, - tagID: 1 + tagID: "weekend" ), [1]: CKRecord( recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), @@ -179,11 +179,10 @@ extension BaseCloudKitTests { title: "Personal" ), [3]: CKRecord( - recordID: CKRecord.ID(1:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(weekend:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, - id: 1, title: "weekend" ) ] diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index e38571f1..31822f41 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -373,5 +373,20 @@ extension BaseCloudKitTests { } #expect(error == CKError(.notAuthenticated)) } + + @Test func incorrectlyCreatingNewRecordIdentity() async throws { + let record1 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [record1]) + let record2 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) + try withKnownIssue { + _ = try syncEngine.modifyRecords(scope: .private, saving: [record2]) + } matching: { issue in + issue.description == """ + Issue recorded: A new identity was created for an existing 'CKRecord' ('1'). Rather than \ + creating 'CKRecord' from scratch for an existing record, use the database to fetch the \ + current record. + """ + } + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 2f112a16..dabfa903 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -191,21 +191,13 @@ extension BaseCloudKitTests { tableName: "tags", schema: """ CREATE TABLE "tags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE ) STRICT """, tableInfo: [ [0]: TableInfo( defaultValue: nil, isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, name: "title", notNull: true, type: "TEXT" @@ -218,7 +210,7 @@ extension BaseCloudKitTests { CREATE TABLE "reminderTags" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """, tableInfo: [ @@ -241,7 +233,7 @@ extension BaseCloudKitTests { isPrimaryKey: false, name: "tagID", notNull: true, - type: "INTEGER" + type: "TEXT" ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index ed31188e..47aeff38 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -30,7 +30,7 @@ extension BaseCloudKitTests { deleting: [RemindersList.recordID(for: 2)] ) try withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try userDatabase.userWrite { db in try Reminder.find(1).update { $0.remindersListID = 2 }.execute(db) @@ -90,14 +90,14 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) } } let modifications = try withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { let reminderRecord = CKRecord( recordType: Reminder.tableName, @@ -174,14 +174,14 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) } } let modifications = try withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { let reminderRecord = CKRecord( recordType: Reminder.tableName, @@ -264,14 +264,14 @@ extension BaseCloudKitTests { deleting: [Parent.recordID(for: 2)] ) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try ChildWithOnDeleteSetNull.find(1).update { $0.parentID = 2 }.execute(db) } } try await withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try await syncEngine.processPendingRecordZoneChanges(scope: .private) await modifications.notify() @@ -344,14 +344,14 @@ extension BaseCloudKitTests { deleting: [Parent.recordID(for: 2)] ) try await withDependencies { - $0.date.now.addTimeInterval(1) + $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try ChildWithOnDeleteSetDefault.find(1).update { $0.parentID = 2 }.execute(db) } } try await withDependencies { - $0.date.now.addTimeInterval(2) + $0.datetime.now.addTimeInterval(2) } operation: { try await syncEngine.processPendingRecordZoneChanges(scope: .private) await modifications.notify() diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift index 352edb6e..c0be6c99 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift @@ -30,7 +30,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { let personalListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: 1) @@ -120,7 +120,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { let personalListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: 1) @@ -179,7 +179,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { let personalListRecord = try syncEngine.private.database.record( for: RemindersList.recordID(for: 1) @@ -240,7 +240,7 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func newTable() async throws { try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { let imageRecord = CKRecord( recordType: "images", diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 6a0010d3..aa6d0880 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -159,7 +159,7 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try db.seed { @@ -321,7 +321,7 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .shared, saving: [modelARecord]).notify() try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try db.seed { @@ -404,7 +404,7 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord, reminderRecord]).notify() try await withDependencies { - $0.date.now.addTimeInterval(60) + $0.datetime.now.addTimeInterval(60) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).delete().execute(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 0494b499..08aafd75 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -170,7 +170,7 @@ extension BaseCloudKitTests { AFTER DELETE ON "tags" FOR EACH ROW BEGIN DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); END """, [15]: """ @@ -300,7 +300,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'tags', NULL, NULL + SELECT "new"."title", 'tags', NULL, NULL ON CONFLICT ("recordPrimaryKey", "recordType") DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END @@ -432,7 +432,7 @@ extension BaseCloudKitTests { FOR EACH ROW BEGIN INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'tags', NULL, NULL + SELECT "new"."title", 'tags', NULL, NULL ON CONFLICT ("recordPrimaryKey", "recordType") DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END diff --git a/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift b/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift new file mode 100644 index 00000000..988cb476 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing +import SharingGRDB + +@Suite struct UserlandTests { + @Test func basics() async throws { + let database = try SharingGRDBTests.database(containerIdentifier: "tests") + let syncEngine = try SyncEngine( + for: database, + tables: ModelA.self, + ModelB.self, + ModelC.self, + containerIdentifier: "tests" + ) + + try await withDependencies { + $0.defaultDatabase = database + $0.defaultSyncEngine = syncEngine + $0.datetime.now = Date.init(timeIntervalSince1970: 1) + } operation: { + @FetchAll var modelAs: [ModelA] = [] + try await database.write { db in + try db.seed { + ModelA.Draft() + } + } + try await $modelAs.load() + #expect(modelAs == [ModelA(id: 1)]) + } + } +} diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 842da86e..76c74479 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -9,7 +9,7 @@ import os @Suite( .snapshots(record: .missing), .dependencies { - $0.date.now = Date(timeIntervalSince1970: 0) + $0.datetime.now = Date(timeIntervalSince1970: 0) $0.dataManager = InMemoryDataManager() } ) @@ -18,7 +18,7 @@ class BaseCloudKitTests: @unchecked Sendable { let userDatabase: UserDatabase private let _syncEngine: any Sendable - @Dependency(\.date.now) var now + @Dependency(\.datetime.now) var now @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var syncEngine: SyncEngine { @@ -148,13 +148,11 @@ extension SyncEngine { MockSyncEngine( database: container.privateCloudDatabase as! MockCloudDatabase, delegate: syncEngine, - scope: .private, state: MockSyncEngineState() ), MockSyncEngine( database: container.sharedCloudDatabase as! MockCloudDatabase, delegate: syncEngine, - scope: .shared, state: MockSyncEngineState() ) ) diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index 58eb5c3b..bb90c86f 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -17,624 +17,7 @@ extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConver } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -final class MockSyncEngine: SyncEngineProtocol { - let database: MockCloudDatabase - let delegate: any SyncEngineDelegate - private let _state: LockIsolated - private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) - private let _acceptedShareMetadata = LockIsolated>([]) - let scope: CKDatabase.Scope - init( - database: MockCloudDatabase, - delegate: any SyncEngineDelegate, - scope: CKDatabase.Scope, - state: MockSyncEngineState - ) { - self.database = database - self.delegate = delegate - self.scope = scope - self._state = LockIsolated(state) - } - - var state: MockSyncEngineState { - _state.withValue(\.self) - } - - func acceptShare(metadata: ShareMetadata) { - _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } - } - - func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { - // TODO: do something here - } - - func recordZoneChangeBatch( - pendingChanges: [CKSyncEngine.PendingRecordZoneChange], - recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - var recordsToSave: [CKRecord] = [] - var recordIDsSkipped: [CKRecord.ID] = [] - var recordIDsToDelete: [CKRecord.ID] = [] - for pendingChange in pendingChanges { - switch pendingChange { - case .saveRecord(let recordID): - guard let record = await recordProvider(recordID) - else { - recordIDsSkipped.append(recordID) - continue - } - recordsToSave.append(record) - case .deleteRecord(let recordID): - recordIDsToDelete.append(recordID) - @unknown default: - fatalError() - } - } - - state.remove(pendingRecordZoneChanges: recordIDsSkipped.map { .saveRecord($0) }) - - return CKSyncEngine.RecordZoneChangeBatch( - recordsToSave: recordsToSave, - recordIDsToDelete: recordIDsToDelete - ) - } - - func assertFetchChangesScopes( - _ scopes: [CKSyncEngine.FetchChangesOptions.Scope], - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - _fetchChangesScopes.withValue { - expectNoDifference( - scopes, - $0, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - $0.removeAll() - } - } - - func assertAcceptedShareMetadata( - _ sharedMetadata: Set, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - _acceptedShareMetadata.withValue { - expectNoDifference( - sharedMetadata, - $0, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - $0.removeAll() - } - } - - func cancelOperations() async { - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpReflectable { - private let _pendingRecordZoneChanges = LockIsolated< - OrderedSet - >([] - ) - private let _pendingDatabaseChanges = LockIsolated< - OrderedSet - >([]) - private let fileID: StaticString - private let filePath: StaticString - private let line: UInt - private let column: UInt - - init( - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column - } - - func assertPendingRecordZoneChanges( - _ changes: OrderedSet, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - _pendingRecordZoneChanges.withValue { - expectNoDifference( - Set(changes), - Set($0), - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - $0.removeAll() - } - } - - func assertPendingDatabaseChanges( - _ changes: OrderedSet, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - _pendingDatabaseChanges.withValue { - expectNoDifference( - Set(changes), - Set($0), - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - $0.removeAll() - } - } - - var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { - _pendingRecordZoneChanges.withValue { Array($0) } - } - - var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { - _pendingDatabaseChanges.withValue { Array($0) } - } - - func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.append(contentsOf: pendingRecordZoneChanges) - } - } - func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.subtract(pendingRecordZoneChanges) - } - } - func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.append(contentsOf: pendingDatabaseChanges) - } - } - func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.subtract(pendingDatabaseChanges) - } - } - - var customDumpMirror: Mirror { - return Mirror( - self, - children: [ - ( - "pendingRecordZoneChanges", - _pendingRecordZoneChanges.withValue(\.self) - .sorted(by: comparePendingRecordZoneChange) - as Any - ), - ( - "pendingDatabaseChanges", - _pendingDatabaseChanges.withValue(\.self) - .sorted(by: comparePendingDatabaseChange) as Any - ), - ], - displayStyle: .struct - ) - } -} - -final class MockCloudDatabase: CloudDatabase { - let storage = LockIsolated<[CKRecordZone.ID: [CKRecord.ID: CKRecord]]>([:]) - let assets = LockIsolated<[AssetID: Data]>([:]) - let databaseScope: CKDatabase.Scope - let _container = IsolatedWeakVar() - - let dataManager = Dependency(\.dataManager) - - struct AssetID: Hashable { - let recordID: CKRecord.ID - let key: String - } - - init(databaseScope: CKDatabase.Scope) { - self.databaseScope = databaseScope - } - - func set(container: MockCloudContainer) { - _container.set(container) - } - - var container: MockCloudContainer { - _container.value! - } - - func record(for recordID: CKRecord.ID) throws -> CKRecord { - let accountStatus = container.accountStatus() - guard accountStatus == .available - else { throw ckError(forAccountStatus: accountStatus) } - guard let zone = storage[recordID.zoneID] - else { throw CKError(.zoneNotFound) } - guard let record = zone[recordID] - else { throw CKError(.unknownItem) } - guard let record = record.copy() as? CKRecord - else { fatalError("Could not copy CKRecord.") } - - try assets.withValue { assets in - for key in record.allKeys() { - guard let assetData = assets[AssetID(recordID: record.recordID, key: key)] - else { continue } - let url = URL(filePath: UUID().uuidString.lowercased()) - try dataManager.wrappedValue.save(assetData, to: url) - record[key] = CKAsset(fileURL: url) - } - } - - return record - } - - func records( - for ids: [CKRecord.ID], - desiredKeys: [CKRecord.FieldKey]? - ) throws -> [CKRecord.ID: Result] { - let accountStatus = container.accountStatus() - guard accountStatus == .available - else { throw ckError(forAccountStatus: accountStatus) } - - var results: [CKRecord.ID: Result] = [:] - for id in ids { - results[id] = Result { try record(for: id) } - } - return results - } - - func modifyRecords( - saving recordsToSave: [CKRecord] = [], - deleting recordIDsToDelete: [CKRecord.ID] = [], - savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged, - atomically: Bool = true - ) throws -> ( - saveResults: [CKRecord.ID: Result], - deleteResults: [CKRecord.ID: Result] - ) { - let accountStatus = container.accountStatus() - guard accountStatus == .available - else { throw ckError(forAccountStatus: accountStatus) } - - return storage.withValue { storage in - var saveResults: [CKRecord.ID: Result] = [:] - var deleteResults: [CKRecord.ID: Result] = [:] - - switch savePolicy { - case .ifServerRecordUnchanged: - for recordToSave in recordsToSave { - guard storage[recordToSave.recordID.zoneID] != nil - else { - saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) - continue - } - - let existingRecord = storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] - - func saveRecordToDatabase() { - let hasReferenceViolation = - recordToSave.parent.map { parent in - storage[parent.recordID.zoneID]?[parent.recordID] == nil - && !recordsToSave.contains { $0.recordID == parent.recordID } - } - ?? false - guard !hasReferenceViolation - else { - saveResults[recordToSave.recordID] = .failure(CKError(.referenceViolation)) - return - } - - guard let copy = recordToSave.copy() as? CKRecord - else { fatalError("Could not copy CKRecord.") } - copy._recordChangeTag = UUID().uuidString - assets.withValue { assets in - for key in copy.allKeys() { - guard let assetURL = (copy[key] as? CKAsset)?.fileURL - else { continue } - assets[AssetID(recordID: copy.recordID, key: key)] = try? dataManager.wrappedValue - .load(assetURL) - } - } - storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy - saveResults[recordToSave.recordID] = .success(copy) - } - - switch (existingRecord, recordToSave._recordChangeTag) { - case (.some(let existingRecord), .some(let recordToSaveChangeTag)): - // We are trying to save a record with a change tag that also already exists in the - // DB. If the tags match, we can save the record. Otherwise, we notify the sync engine - // that the server record has changed since it was last synced. - if existingRecord._recordChangeTag == recordToSaveChangeTag { - precondition(existingRecord._recordChangeTag != nil) - saveRecordToDatabase() - } else { - saveResults[recordToSave.recordID] = .failure( - CKError( - .serverRecordChanged, - userInfo: [ - CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, - CKRecordChangedErrorClientRecordKey: recordToSave.copy(), - ] - ) - ) - } - break - case (.some(let existingRecord), .none): - // We are trying to save a record that does not have a change tag yet also already - // exists in the DB. This means the user has created a new CKRecord from scratch, - // giving it a new identity, rather than leveraging an existing CKRecord. - Issue.record( - """ - A new identity was created for an existing 'CKRecord' \ - ('\(existingRecord.recordID.recordName)'). Rather than creating \ - 'CKRecord' from scratch for an existing record, use the database to fetch the \ - current record. - """ - ) - saveResults[recordToSave.recordID] = .failure( - CKError( - .serverRejectedRequest, - userInfo: [ - CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, - CKRecordChangedErrorClientRecordKey: recordToSave.copy(), - ] - ) - ) - case (.none, .some): - // We are trying to save a record with a change tag but it does not exist in the DB. - // This means the record was deleted by another device. - saveResults[recordToSave.recordID] = .failure(CKError(.unknownItem)) - case (.none, .none): - // We are trying to save a record with no change tag and no existing record in the DB. - // This means it's a brand new record. - saveRecordToDatabase() - } - } - case .allKeys, .changedKeys: - fatalError() - @unknown default: - fatalError() - } - for recordIDToDelete in recordIDsToDelete { - guard storage[recordIDToDelete.zoneID] != nil - else { - deleteResults[recordIDToDelete] = .failure(CKError(.zoneNotFound)) - continue - } - let hasReferenceViolation = !Set( - storage[recordIDToDelete.zoneID]?.values - .compactMap { $0.parent?.recordID == recordIDToDelete ? $0.recordID : nil } - ?? [] - ) - .subtracting(recordIDsToDelete) - .isEmpty - - guard !hasReferenceViolation - else { - deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) - continue - } - storage[recordIDToDelete.zoneID]?[recordIDToDelete] = nil - deleteResults[recordIDToDelete] = .success(()) - } - - return (saveResults: saveResults, deleteResults: deleteResults) - } - } - - func modifyRecordZones( - saving recordZonesToSave: [CKRecordZone] = [], - deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] - ) throws -> ( - saveResults: [CKRecordZone.ID: Result], - deleteResults: [CKRecordZone.ID: Result] - ) { - let accountStatus = container.accountStatus() - guard accountStatus == .available - else { throw ckError(forAccountStatus: accountStatus) } - - return storage.withValue { storage in - var saveResults: [CKRecordZone.ID: Result] = [:] - var deleteResults: [CKRecordZone.ID: Result] = [:] - - for recordZoneToSave in recordZonesToSave { - storage[recordZoneToSave.zoneID] = storage[recordZoneToSave.zoneID] ?? [:] - saveResults[recordZoneToSave.zoneID] = .success(recordZoneToSave) - } - - for recordZoneIDsToDelete in recordZoneIDsToDelete { - guard storage[recordZoneIDsToDelete] != nil - else { - deleteResults[recordZoneIDsToDelete] = .failure(CKError(.zoneNotFound)) - continue - } - storage[recordZoneIDsToDelete] = nil - deleteResults[recordZoneIDsToDelete] = .success(()) - } - - return (saveResults: saveResults, deleteResults: deleteResults) - } - } - - nonisolated static func == (lhs: MockCloudDatabase, rhs: MockCloudDatabase) -> Bool { - lhs === rhs - } - - nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } -} - -extension MockCloudDatabase: CustomDumpReflectable { - var customDumpMirror: Mirror { - Mirror( - self, - children: [ - "databaseScope": databaseScope, - "storage": storage - .value - .flatMap { _, value in value.values } - .sorted { - ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) - }, - ], - displayStyle: .struct - ) - } -} - -final class MockCloudContainer: CloudContainer, CustomDumpReflectable { - let _accountStatus: LockIsolated - let containerIdentifier: String? - let privateCloudDatabase: MockCloudDatabase - let sharedCloudDatabase: MockCloudDatabase - - init( - accountStatus: CKAccountStatus = .available, - containerIdentifier: String?, - privateCloudDatabase: MockCloudDatabase, - sharedCloudDatabase: MockCloudDatabase - ) { - self._accountStatus = LockIsolated(accountStatus) - self.containerIdentifier = containerIdentifier - self.privateCloudDatabase = privateCloudDatabase - self.sharedCloudDatabase = sharedCloudDatabase - } - - func accountStatus() -> CKAccountStatus { - _accountStatus.withValue(\.self) - } - - var rawValue: CKContainer { - fatalError("This should never be called in tests.") - } - - func accountStatus() async throws -> CKAccountStatus { - _accountStatus.withValue { $0 } - } - - func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata { - fatalError() - } - - func accept(_ metadata: CKShare.Metadata) async throws -> CKShare { - fatalError() - } - - static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { - @Dependency(\.mockCloudContainers) var mockCloudContainers - return mockCloudContainers.withValue { storage in - let container: MockCloudContainer - if let existingContainer = storage[containerIdentifier] { - container = existingContainer - } else { - container = MockCloudContainer( - accountStatus: .available, - containerIdentifier: containerIdentifier, - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ) - container.privateCloudDatabase.set(container: container) - container.sharedCloudDatabase.set(container: container) - } - storage[containerIdentifier] = container - return container - } - } - - static func == (lhs: MockCloudContainer, rhs: MockCloudContainer) -> Bool { - lhs === rhs - } - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } - var customDumpMirror: Mirror { - Mirror.init( - self, - children: [ - ("privateCloudDatabase", privateCloudDatabase), - ("sharedCloudDatabase", sharedCloudDatabase), - ], - displayStyle: .struct - ) - } -} - -private enum MockCloudContainersKey: TestDependencyKey { - static var testValue: LockIsolated<[String: MockCloudContainer]> { - LockIsolated<[String: MockCloudContainer]>([:]) - } -} -extension DependencyValues { - var mockCloudContainers: LockIsolated<[String: MockCloudContainer]> { - get { - self[MockCloudContainersKey.self] - } - set { - self[MockCloudContainersKey.self] = newValue - } - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private func comparePendingRecordZoneChange( - _ lhs: CKSyncEngine.PendingRecordZoneChange, - _ rhs: CKSyncEngine.PendingRecordZoneChange -) -> Bool { - switch (lhs, rhs) { - case (.saveRecord(let lhs), .saveRecord(let rhs)), - (.deleteRecord(let lhs), .deleteRecord(let rhs)): - lhs.recordName < rhs.recordName - case (.deleteRecord, .saveRecord): - true - case (.saveRecord, .deleteRecord): - false - default: - false - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private func comparePendingDatabaseChange( - _ lhs: CKSyncEngine.PendingDatabaseChange, - _ rhs: CKSyncEngine.PendingDatabaseChange -) -> Bool { - switch (lhs, rhs) { - case (.saveZone(let lhs), .saveZone(let rhs)): - lhs.zoneID.zoneName < rhs.zoneID.zoneName - case (.deleteZone(let lhs), .deleteZone(let rhs)): - lhs.zoneName < rhs.zoneName - case (.deleteZone, .saveZone): - true - case (.saveZone, .deleteZone): - false - default: - false - } -} extension SyncEngine { struct ModifyRecordsCallback { @@ -929,16 +312,3 @@ extension SyncEngine { } } } - -private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError { - switch accountStatus { - case .couldNotDetermine, .restricted, .noAccount: - return CKError(.notAuthenticated) - case .temporarilyUnavailable: - return CKError(.accountTemporarilyUnavailable) - case .available: - fatalError() - @unknown default: - fatalError() - } -} diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index db60e072..b499eced 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -27,8 +27,9 @@ import SharingGRDB var remindersListID: RemindersList.ID } @Table struct Tag: Equatable, Identifiable { - let id: Int - var title = "" + @Column(primaryKey: true) + let title: String + var id: String { title } } @Table struct ReminderTag: Equatable, Identifiable { let id: Int @@ -51,16 +52,16 @@ import SharingGRDB var name = "" var parentID: LocalUser.ID? } -@Table struct ModelA: Identifiable { +@Table struct ModelA: Equatable, Identifiable { let id: Int var count = 0 } -@Table struct ModelB: Identifiable { +@Table struct ModelB: Equatable, Identifiable { let id: Int var isOn = false var modelAID: ModelA.ID } -@Table struct ModelC: Identifiable { +@Table struct ModelC: Equatable, Identifiable { let id: Int var title = "" var modelBID: ModelB.ID @@ -128,8 +129,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql( """ CREATE TABLE "tags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE ) STRICT """ ) @@ -139,7 +139,7 @@ func database(containerIdentifier: String) throws -> DatabasePool { CREATE TABLE "reminderTags" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT """ ) From b250b7612512b8d53e57aca65088e18b04ce4e7b Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:16:46 -0500 Subject: [PATCH 484/581] Handling more CloudKit sharing edge cases (#126) * wip * wip * wip * wip * wip * wip * wip * wip * wip * clean up * wip * wip * wip * wip --- Examples/Reminders/ReminderForm.swift | 2 +- .../CloudKit/Metadatabase.swift | 1 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 180 ++++- .../SyncMetadata+MacroExpansion.swift | 166 ++++- .../CloudKit/SyncMetadata.swift | 24 + .../SharingGRDBCore/CloudKit/Triggers.swift | 410 ++++++----- .../Articles/CloudKitSharing.md | 14 +- .../CloudKitTests/CloudKitTests.swift | 3 +- .../ForeignKeyConstraintTests.swift | 55 ++ .../CloudKitTests/NewTableSyncTests.swift | 2 + .../CloudKitTests/SharingTests.swift | 1 + .../CloudKitTests/TriggerTests.swift | 678 ++++++++++++++++-- .../Internal/BaseCloudKitTests.swift | 2 - 13 files changed, 1270 insertions(+), 268 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 4fc55ec0..4f37515b 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -184,8 +184,8 @@ struct ReminderFormView: View { } .execute(db) } + dismiss() } - dismiss() } } diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index cb8cad7d..fc4e2e90 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -63,6 +63,7 @@ func defaultMetadatabase( "share" BLOB, "isShared" INTEGER NOT NULL AS ("share" IS NOT NULL), "userModificationDate" TEXT NOT NULL DEFAULT (\(.datetime())), + "_isDeleted" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY ("recordPrimaryKey", "recordType"), UNIQUE ("recordName") diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4ae48eab..005b4794 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -24,8 +24,8 @@ @Sendable (any DatabaseReader, SyncEngine) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) package let container: any CloudContainer - let dataManager = Dependency(\.dataManager) + public static let writePermissionError = "co.pointfree.sqlitedata-icloud.write-permission-error" public convenience init( for database: any DatabaseWriter, @@ -33,13 +33,15 @@ privateTables: repeat (each T2).Type, containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), - logger: Logger = isTesting ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") + logger: Logger = isTesting + ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") ) throws where repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible { - let containerIdentifier = containerIdentifier + let containerIdentifier = + containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier var allTables: [any PrimaryKeyedTable.Type] = [] @@ -253,6 +255,7 @@ db.add(function: .syncEngineIsSynchronizingChanges) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) + db.add(function: .hasPermission) for trigger in SyncMetadata.callbackTriggers { try trigger.execute(db) @@ -407,6 +410,7 @@ for trigger in SyncMetadata.callbackTriggers.reversed() { try trigger.drop().execute(db) } + db.remove(function: .hasPermission) db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .syncEngineIsSynchronizingChanges) @@ -455,21 +459,23 @@ ) } - func didDelete(recordName: String, zoneID: CKRecordZone.ID?) { + func didDelete(recordName: String, zoneID: CKRecordZone.ID?, share: CKShare?) { let zoneID = zoneID ?? defaultZone.zoneID let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .deleteRecord( - CKRecord.ID( - recordName: recordName, - zoneID: zoneID - ) + var changes: [CKSyncEngine.PendingRecordZoneChange] = [ + .deleteRecord( + CKRecord.ID( + recordName: recordName, + zoneID: zoneID ) - ] - ) + ) + ] + if let share { + changes.append(.deleteRecord(share.recordID)) + } + syncEngine?.state.add(pendingRecordZoneChanges: changes) } // TODO: Possible to get test coverage on this? @@ -594,11 +600,11 @@ options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all), syncEngine: any SyncEngineProtocol ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let allChanges = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) - guard !allChanges.isEmpty + var changes = await pendingRecordZoneChanges(options: options, syncEngine: syncEngine) + guard !changes.isEmpty else { return nil } - let changes = allChanges.sorted { lhs, rhs in + changes.sort { lhs, rhs in switch (lhs, rhs) { case (.saveRecord(let lhs), .saveRecord(let rhs)): guard @@ -753,6 +759,101 @@ return batch } + private func pendingRecordZoneChanges( + options: CKSyncEngine.SendChangesOptions, + syncEngine: any SyncEngineProtocol + ) async -> [CKSyncEngine.PendingRecordZoneChange] { + var changes = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) + guard !changes.isEmpty + else { return [] } + + let deletedRecordIDs: [CKRecord.ID] = changes.compactMap { + switch $0 { + case .saveRecord(_): + return nil + case .deleteRecord(let recordID): + return recordID + @unknown default: + return nil + } + } + let deletedRecordNames = deletedRecordIDs.map(\.recordName) + + let (metadataOfDeletions, recordsWithRoot): ([SyncMetadata], [RecordWithRoot]) = + await withErrorReporting { + try await userDatabase.read { db in + let metadataOfDeletions = try SyncMetadata.where { + $0.recordName.in(deletedRecordNames) + } + .fetchAll(db) + + let recordsWithRoot = + try With { + SyncMetadata + .where { $0.parentRecordName.is(nil) && $0.recordName.in(deletedRecordNames) } + .select { + RecordWithRoot.Columns( + parentRecordName: $0.parentRecordName, + recordName: $0.recordName, + lastKnownServerRecord: $0.lastKnownServerRecord, + rootRecordName: $0.recordName, + rootLastKnownServerRecord: $0.lastKnownServerRecord + ) + } + .union( + all: true, + SyncMetadata + .join(RecordWithRoot.all) { $1.recordName.is($0.parentRecordName) } + .select { metadata, tree in + RecordWithRoot.Columns( + parentRecordName: metadata.parentRecordName, + recordName: metadata.recordName, + lastKnownServerRecord: metadata.lastKnownServerRecord, + rootRecordName: tree.rootRecordName, + rootLastKnownServerRecord: tree.lastKnownServerRecord + ) + } + ) + } query: { + RecordWithRoot + .where { $0.recordName.in(deletedRecordNames) } + } + .fetchAll(db) + + return (metadataOfDeletions, recordsWithRoot) + } + } + ?? ([], []) + + let shareRecordIDsToDelete = metadataOfDeletions.compactMap(\.share?.recordID) + + for recordWithRoot in recordsWithRoot { + guard + let lastKnownServerRecord = recordWithRoot.lastKnownServerRecord, + let rootLastKnownServerRecord = recordWithRoot.rootLastKnownServerRecord + else { continue } + guard let rootShareRecordID = rootLastKnownServerRecord.share?.recordID + else { continue } + guard shareRecordIDsToDelete.contains(rootShareRecordID) + else { continue } + changes.removeAll(where: { $0 == .deleteRecord(lastKnownServerRecord.recordID) }) + syncEngine.state.remove( + pendingRecordZoneChanges: [.deleteRecord(lastKnownServerRecord.recordID)] + ) + } + + await withErrorReporting { + try await userDatabase.write { db in + try SyncMetadata + .where { $0.recordName.in(deletedRecordNames) } + .delete() + .execute(db) + } + } + + return changes + } + package func handleAccountChange( changeType: CKSyncEngine.Event.AccountChange.ChangeType, syncEngine: any SyncEngineProtocol @@ -1152,6 +1253,7 @@ } private func cacheShare(_ share: CKShare) async throws { + // TODO: Instead of getting URL here we can make `shareMetadata(…)` take a share instead of a URL guard let url = share.url else { return } @@ -1416,7 +1518,7 @@ @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { recordName, zoneID in + Self("didUpdate") { recordName, zoneID, _ in syncEngine.didUpdate( recordName: recordName, zoneID: zoneID @@ -1425,8 +1527,13 @@ } fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { - return Self("didDelete") { recordName, zoneID in - syncEngine.didDelete(recordName: recordName, zoneID: zoneID) + return Self("didDelete") { recordName, zoneID, share in + syncEngine + .didDelete( + recordName: recordName, + zoneID: zoneID, + share: share + ) } } @@ -1442,6 +1549,22 @@ } } + fileprivate static var hasPermission: Self { + Self(.sqliteDataCloudKitSchemaName + "_hasPermission", argumentCount: 1) { arguments in + let share = try Data.fromDatabaseValue(arguments[0]).flatMap { + let coder = try NSKeyedUnarchiver(forReadingFrom: $0) + coder.requiresSecureCoding = true + return CKShare(coder: coder) + } + guard let share + else { return true } + let hasPermission = + share.publicPermission == .readWrite + || share.currentUserParticipant?.permission == .readWrite + return hasPermission + } + } + fileprivate static var syncEngineIsSynchronizingChanges: Self { Self( .sqliteDataCloudKitSchemaName + "_" + "syncEngineIsSynchronizingChanges", @@ -1454,9 +1577,9 @@ private convenience init( _ name: String, - function: @escaping @Sendable (String, CKRecordZone.ID?) -> Void + function: @escaping @Sendable (String, CKRecordZone.ID?, CKShare?) -> Void ) { - self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 2) { arguments in + self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 3) { arguments in guard let recordName = String.fromDatabaseValue(arguments[0]) else { @@ -1467,7 +1590,14 @@ coder.requiresSecureCoding = true return CKRecord(coder: coder)?.recordID.zoneID } - function(recordName, zoneID) + + let share = try Data.fromDatabaseValue(arguments[2]).flatMap { + let coder = try NSKeyedUnarchiver(forReadingFrom: $0) + coder.requiresSecureCoding = true + return CKShare(coder: coder) + } + + function(recordName, zoneID, share) return nil } } @@ -1493,7 +1623,8 @@ else { return URL(string: "file:\(String.sqliteDataCloudKitSchemaName)?mode=memory&cache=shared")! } - return databaseURL + return + databaseURL .deletingLastPathComponent() .appending(component: ".\(databaseURL.deletingPathExtension().lastPathComponent)") .appendingPathExtension("metadata\(containerIdentifier.map { "-\($0)" } ?? "").sqlite") @@ -1561,7 +1692,8 @@ /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize /// data. public func attachMetadatabase(containerIdentifier: String? = nil) throws { - let containerIdentifier = containerIdentifier + let containerIdentifier = + containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier guard let containerIdentifier else { diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index d6928a25..52ae476f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -48,6 +48,12 @@ keyPath: \QueryValue.isShared ) } + public var _isDeleted: StructuredQueriesCore.TableColumn { + StructuredQueriesCore.TableColumn( + "_isDeleted", + keyPath: \QueryValue._isDeleted + ) + } public let userModificationDate = StructuredQueriesCore.TableColumn( "userModificationDate", keyPath: \QueryValue.userModificationDate @@ -59,7 +65,8 @@ QueryValue.columns.parentRecordType, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns._lastKnownServerRecordAllFields, QueryValue.columns.share, - QueryValue.columns.isShared, QueryValue.columns.userModificationDate, + QueryValue.columns.isShared, QueryValue.columns._isDeleted, + QueryValue.columns.userModificationDate, ] } public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { @@ -68,11 +75,12 @@ QueryValue.columns.parentRecordPrimaryKey, QueryValue.columns.parentRecordType, QueryValue.columns.lastKnownServerRecord, QueryValue.columns._lastKnownServerRecordAllFields, QueryValue.columns.share, + QueryValue.columns._isDeleted, QueryValue.columns.userModificationDate, ] } public var queryFragment: QueryFragment { - "\(self.recordPrimaryKey), \(self.recordType), \(self.recordName), \(self.parentRecordPrimaryKey), \(self.parentRecordType), \(self.parentRecordName), \(self.lastKnownServerRecord), \(self._lastKnownServerRecordAllFields), \(self.share), \(self.isShared), \(self.userModificationDate)" + "\(self.recordPrimaryKey), \(self.recordType), \(self.recordName), \(self.parentRecordPrimaryKey), \(self.parentRecordType), \(self.parentRecordName), \(self.lastKnownServerRecord), \(self._lastKnownServerRecordAllFields), \(self.share), \(self.isShared), \(self._isDeleted), \(self.userModificationDate)" } } } @@ -98,6 +106,7 @@ ) let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) let isShared = try decoder.decode(Bool.self) + let _isDeleted = try decoder.decode(Bool.self) let userModificationDate = try decoder.decode(Date.self) guard let recordPrimaryKey else { throw QueryDecodingError.missingRequiredColumn @@ -120,6 +129,9 @@ guard let isShared else { throw QueryDecodingError.missingRequiredColumn } + guard let _isDeleted else { + throw QueryDecodingError.missingRequiredColumn + } guard let userModificationDate else { throw QueryDecodingError.missingRequiredColumn } @@ -130,6 +142,7 @@ self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields self.share = share self.isShared = isShared + self._isDeleted = _isDeleted self.userModificationDate = userModificationDate } } @@ -214,4 +227,153 @@ self.lastKnownServerRecord = lastKnownServerRecord } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension RecordWithRoot { + public struct Columns: StructuredQueriesCore.QueryExpression { + public typealias QueryValue = RecordWithRoot + public let queryFragment: StructuredQueriesCore.QueryFragment + public init( + parentRecordName: some StructuredQueriesCore.QueryExpression, + recordName: some StructuredQueriesCore.QueryExpression, + lastKnownServerRecord: some StructuredQueriesCore.QueryExpression< + CKRecord?.SystemFieldsRepresentation + >, + rootRecordName: some StructuredQueriesCore.QueryExpression, + rootLastKnownServerRecord: some StructuredQueriesCore.QueryExpression< + CKRecord?.SystemFieldsRepresentation + > + ) { + self.queryFragment = """ + \(parentRecordName.queryFragment) AS "parentRecordName", \(recordName.queryFragment) AS "recordName", \(lastKnownServerRecord.queryFragment) AS "lastKnownServerRecord", \(rootRecordName.queryFragment) AS "rootRecordName", \(rootLastKnownServerRecord.queryFragment) AS "rootLastKnownServerRecord" + """ + } + } + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = RecordWithRoot + public let parentRecordName = StructuredQueriesCore.TableColumn( + "parentRecordName", + keyPath: \QueryValue.parentRecordName + ) + public let recordName = StructuredQueriesCore.TableColumn( + "recordName", + keyPath: \QueryValue.recordName + ) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.SystemFieldsRepresentation + >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let rootRecordName = StructuredQueriesCore.TableColumn( + "rootRecordName", + keyPath: \QueryValue.rootRecordName + ) + public let rootLastKnownServerRecord = StructuredQueriesCore.TableColumn< + QueryValue, CKRecord?.SystemFieldsRepresentation + >("rootLastKnownServerRecord", keyPath: \QueryValue.rootLastKnownServerRecord) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [ + QueryValue.columns.parentRecordName, QueryValue.columns.recordName, + QueryValue.columns.lastKnownServerRecord, QueryValue.columns.rootRecordName, + QueryValue.columns.rootLastKnownServerRecord, + ] + } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [ + QueryValue.columns.parentRecordName, QueryValue.columns.recordName, + QueryValue.columns.lastKnownServerRecord, QueryValue.columns.rootRecordName, + QueryValue.columns.rootLastKnownServerRecord, + ] + } + public var queryFragment: QueryFragment { + "\(self.parentRecordName), \(self.recordName), \(self.lastKnownServerRecord), \(self.rootRecordName), \(self.rootLastKnownServerRecord)" + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + nonisolated extension RecordWithRoot: StructuredQueriesCore.Table { + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "recordWithRoots" + } + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + self.parentRecordName = try decoder.decode(String.self) + let recordName = try decoder.decode(String.self) + let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) + let rootRecordName = try decoder.decode(String.self) + let rootLastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) + guard let recordName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let lastKnownServerRecord else { + throw QueryDecodingError.missingRequiredColumn + } + guard let rootRecordName else { + throw QueryDecodingError.missingRequiredColumn + } + guard let rootLastKnownServerRecord else { + throw QueryDecodingError.missingRequiredColumn + } + self.recordName = recordName + self.lastKnownServerRecord = lastKnownServerRecord + self.rootRecordName = rootRecordName + self.rootLastKnownServerRecord = rootLastKnownServerRecord + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension RootShare { + public struct Columns: StructuredQueriesCore.QueryExpression { + public typealias QueryValue = RootShare + public let queryFragment: StructuredQueriesCore.QueryFragment + public init( + parentRecordName: some StructuredQueriesCore.QueryExpression, + share: some StructuredQueriesCore.QueryExpression + ) { + self.queryFragment = """ + \(parentRecordName.queryFragment) AS "parentRecordName", \(share.queryFragment) AS "share" + """ + } + } + + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = RootShare + public let parentRecordName = StructuredQueriesCore.TableColumn( + "parentRecordName", + keyPath: \QueryValue.parentRecordName + ) + public let share = StructuredQueriesCore.TableColumn< + QueryValue, CKShare?.SystemFieldsRepresentation + >("share", keyPath: \QueryValue.share) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.parentRecordName, QueryValue.columns.share] + } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [QueryValue.columns.parentRecordName, QueryValue.columns.share] + } + public var queryFragment: QueryFragment { + "\(self.parentRecordName), \(self.share)" + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + nonisolated extension RootShare: StructuredQueriesCore.Table { + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "rootShares" + } + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + self.parentRecordName = try decoder.decode(String.self) + let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) + guard let share else { + throw QueryDecodingError.missingRequiredColumn + } + self.share = share + } + } + #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 85676907..3a4833a8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -60,6 +60,10 @@ // @Column(as: CKShare?.SystemFieldsRepresentation.self) public var share: CKShare? + /// Determines if the metadata has been "soft" deleted. It will be fully deleted once the + /// next batch of pending changes is processed. + public var _isDeleted = false + // @Column(generated: .virtual) public let isShared: Bool @@ -76,6 +80,26 @@ let lastKnownServerRecord: CKRecord? } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + // @Table @Selection + struct RecordWithRoot { + let parentRecordName: String? + let recordName: String + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let lastKnownServerRecord: CKRecord? + let rootRecordName: String + // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + let rootLastKnownServerRecord: CKRecord? + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + // @Table @Selection + struct RootShare { + let parentRecordName: String? + // @Column(as: CKShare?.SystemFieldsRepresentation.self) + let share: CKShare? + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { package init( diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index e2dacc02..65f11912 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -1,190 +1,280 @@ #if canImport(CloudKit) -import CloudKit -import Foundation - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTable { - static func metadataTriggers(parentForeignKey: ForeignKey?) -> [TemporaryTrigger] { - [ - afterInsert(parentForeignKey: parentForeignKey), - afterUpdate(parentForeignKey: parentForeignKey), - afterDelete, - ] + import CloudKit + import Foundation + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PrimaryKeyedTable { + static func metadataTriggers(parentForeignKey: ForeignKey?) -> [TemporaryTrigger] { + [ + afterInsert(parentForeignKey: parentForeignKey), + afterUpdate(parentForeignKey: parentForeignKey), + afterDeleteFromUser(parentForeignKey: parentForeignKey), + afterDeleteFromSyncEngine, + ] + } + + fileprivate static func afterInsert(parentForeignKey: ForeignKey?) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", + ifNotExists: true, + after: .insert { new in + checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) + SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) + } + ) + } + + fileprivate static func afterUpdate(parentForeignKey: ForeignKey?) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", + ifNotExists: true, + after: .update { _, new in + checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) + SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) + } + ) + } + + fileprivate static func afterDeleteFromUser(parentForeignKey: ForeignKey?) -> TemporaryTrigger< + Self + > { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_user", + ifNotExists: true, + after: .delete { old in + checkWritePermissions(alias: old, parentForeignKey: parentForeignKey) + SyncMetadata + .where { + $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) + && $0.recordType.eq(tableName) + } + .update { $0._isDeleted = true } + } when: { _ in + !SyncEngine.isSynchronizingChanges() + } + ) + } + + fileprivate static var afterDeleteFromSyncEngine: TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_sync_engine", + ifNotExists: true, + after: .delete { old in + SyncMetadata + .where { + $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) + && $0.recordType.eq(tableName) + } + .delete() + } when: { _ in + SyncEngine.isSynchronizingChanges() + } + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { + fileprivate static func upsert( + new: StructuredQueriesCore.TableAlias.TableColumns, + parentForeignKey: ForeignKey?, + ) -> some StructuredQueriesCore.Statement { + let (parentRecordPrimaryKey, parentRecordType) = parentFields( + alias: new, + parentForeignKey: parentForeignKey + ) + return insert { + ($0.recordPrimaryKey, $0.recordType, $0.parentRecordPrimaryKey, $0.parentRecordType) + } select: { + Values( + SQLQueryExpression("\(new.primaryKey)"), + T.tableName, + SQLQueryExpression(parentRecordPrimaryKey), + SQLQueryExpression(parentRecordType) + ) + } onConflict: { + ($0.recordPrimaryKey, $0.recordType) + } doUpdate: { + $0.parentRecordPrimaryKey = $1.parentRecordPrimaryKey + $0.parentRecordType = $1.parentRecordType + $0.userModificationDate = $1.userModificationDate + } + } } - fileprivate static func afterInsert(parentForeignKey: ForeignKey?) -> TemporaryTrigger { - createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { + static var callbackTriggers: [TemporaryTrigger] { + [ + afterInsertTrigger, + afterUpdateTrigger, + afterSoftDeleteTrigger, + ] + } + + private enum ParentSyncMetadata: AliasName {} + + fileprivate static let afterInsertTrigger = createTemporaryTrigger( + "after_insert_on_sqlitedata_icloud_metadata", ifNotExists: true, - after: .insert { new in SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } + after: .insert { new in + Values(.didUpdate(new)) + } when: { _ in + !SyncEngine.isSynchronizingChanges() + } ) - } - fileprivate static func afterUpdate(parentForeignKey: ForeignKey?) -> TemporaryTrigger { - createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", + fileprivate static let afterUpdateTrigger = createTemporaryTrigger( + "after_update_on_sqlitedata_icloud_metadata", ifNotExists: true, - after: .update { _, new in SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } + after: .update { _, new in + Values(.didUpdate(new)) + } when: { old, new in + old._isDeleted.eq(new._isDeleted) && !SyncEngine.isSynchronizingChanges() + } ) - } - fileprivate static var afterDelete: TemporaryTrigger { - createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)", + fileprivate static let afterSoftDeleteTrigger = createTemporaryTrigger( + "after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, - after: .delete { old in - SyncMetadata - .where { - $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) - && $0.recordType.eq(tableName) - } - .delete() + after: .update(of: \._isDeleted) { _, new in + Values( + .didDelete( + recordName: new.recordName, + lastKnownServerRecord: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName), + share: new.share + ) + ) + } when: { old, new in + !old._isDeleted && new._isDeleted && !SyncEngine.isSynchronizingChanges() } ) } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - fileprivate static func upsert( - new: TemporaryTrigger.Operation.New, - parentForeignKey: ForeignKey?, - ) -> some StructuredQueriesCore.Statement { - let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = - parentForeignKey - .map { (#""new".\#(quote: $0.from)"#, "\(bind: $0.table)") } - ?? ("NULL", "NULL") - return insert { - ($0.recordPrimaryKey, $0.recordType, $0.parentRecordPrimaryKey, $0.parentRecordType) - } select: { - Values( - SQLQueryExpression("\(new.primaryKey)"), - T.tableName, - SQLQueryExpression(parentRecordPrimaryKey), - SQLQueryExpression(parentRecordType) + + extension QueryExpression where Self == SQLQueryExpression<()> { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func didUpdate( + _ new: StructuredQueriesCore.TableAlias< + SyncMetadata, TemporaryTrigger.Operation._New + > + .TableColumns + ) -> Self { + .didUpdate( + recordName: new.recordName, + lastKnownServerRecord: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName), + share: new.share ) - } onConflict: { - ($0.recordPrimaryKey, $0.recordType) - } doUpdate: { - $0.parentRecordPrimaryKey = $1.parentRecordPrimaryKey - $0.parentRecordType = $1.parentRecordType - $0.userModificationDate = $1.userModificationDate } - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncMetadata { - static var callbackTriggers: [TemporaryTrigger] { - [ - afterInsertTrigger, - afterUpdateTrigger, - afterDeleteTrigger, - ] - } - - private enum ParentSyncMetadata: AliasName {} - fileprivate static let afterInsertTrigger = createTemporaryTrigger( - "after_insert_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .insert { new in - Values(.didUpdate(new)) - } when: { _ in - !SyncEngine.isSynchronizingChanges() - } - ) - - fileprivate static let afterUpdateTrigger = createTemporaryTrigger( - "after_update_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .update { _, new in - Values(.didUpdate(new)) - } when: { _, _ in - !SyncEngine.isSynchronizingChanges() + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private static func didUpdate( + recordName: some QueryExpression, + lastKnownServerRecord: some QueryExpression, + share: some QueryExpression + ) -> Self { + Self( + "\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(recordName), \(lastKnownServerRecord), \(share))" + ) } - ) - - fileprivate static let afterDeleteTrigger = createTemporaryTrigger( - "after_delete_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .delete { old in - Values(.didDelete( - recordName: old.recordName, - lastKnownServerRecord: old.lastKnownServerRecord - ?? rootServerRecord(recordName: old.recordName) - )) - } when: { _ in - !SyncEngine.isSynchronizingChanges() + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + fileprivate static func didDelete( + recordName: some QueryExpression, + lastKnownServerRecord: some QueryExpression, + share: some QueryExpression + ) -> Self { + Self( + "\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord), \(share))" + ) } - ) -} + } + + private func isUpdatingWithServerRecord() -> SQLQueryExpression { + SQLQueryExpression("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + } + + private func parentFields( + alias: StructuredQueriesCore.TableAlias.TableColumns, + parentForeignKey: ForeignKey? + ) -> (parentRecordPrimaryKey: QueryFragment, parentRecordType: QueryFragment) { + parentForeignKey + .map { (#"\#(type(of: alias).QueryValue.self).\#(quote: $0.from)"#, "\(bind: $0.table)") } + ?? ("NULL", "NULL") + } -extension QueryExpression where Self == SQLQueryExpression<()> { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func didUpdate( - _ new: StructuredQueriesCore.TableAlias< - SyncMetadata, TemporaryTrigger.Operation._New - > - .TableColumns - ) -> Self { - .didUpdate( - recordName: new.recordName, - lastKnownServerRecord: new.lastKnownServerRecord - ?? rootServerRecord(recordName: new.recordName) + private func checkWritePermissions( + alias: StructuredQueriesCore.TableAlias.TableColumns, + parentForeignKey: ForeignKey? + ) -> some StructuredQueriesCore.Statement { + let (parentRecordPrimaryKey, parentRecordType) = parentFields( + alias: alias, + parentForeignKey: parentForeignKey ) + + return With { + SyncMetadata + .where { + $0.recordPrimaryKey.is(SQLQueryExpression(parentRecordPrimaryKey)) + && $0.recordType.is(SQLQueryExpression(parentRecordType)) + } + .select { RootShare.Columns(parentRecordName: $0.parentRecordName, share: $0.share) } + .union( + all: true, + SyncMetadata + .select { + RootShare.Columns(parentRecordName: $0.parentRecordName, share: $0.share) + } + .join(RootShare.all) { $0.recordName.is($1.parentRecordName) } + ) + } query: { + RootShare + .select { _ in + SQLQueryExpression( + "RAISE(ABORT, \(quote: SyncEngine.writePermissionError, delimiter: .text))", + as: Never.self + ) + } + .where { + $0.parentRecordName.is(nil) + && !SQLQueryExpression( + "\(raw: String.sqliteDataCloudKitSchemaName)_hasPermission(\($0.share))" + ) + } + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - private static func didUpdate( - recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression - ) -> Self { - Self("\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(recordName), \(lastKnownServerRecord))") + private func rootServerRecord( + recordName: some QueryExpression + ) -> some QueryExpression { + With { + SyncMetadata + .where { $0.recordName.eq(recordName) } + .select { AncestorMetadata.Columns($0) } + .union( + all: true, + SyncMetadata + .select { AncestorMetadata.Columns($0) } + .join(AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) } + ) + } query: { + AncestorMetadata + .select(\.lastKnownServerRecord) + .where { $0.parentRecordName.is(nil) } + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func didDelete( - recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression - ) -> Self { - Self("\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord))") - } -} - -private func isUpdatingWithServerRecord() -> SQLQueryExpression { - SQLQueryExpression("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private func rootServerRecord( - recordName: some QueryExpression -) -> some QueryExpression { - With { - SyncMetadata - .where { $0.recordName.eq(recordName) } - .select { AncestorMetadata.Columns($0) } - .union( - all: true, - SyncMetadata - .select { AncestorMetadata.Columns($0) } - .join(AncestorMetadata.all) { $0.recordName.is($1.parentRecordName) } + extension AncestorMetadata.Columns { + init(_ metadata: SyncMetadata.TableColumns) { + self.init( + recordName: metadata.recordName, + parentRecordName: metadata.parentRecordName, + lastKnownServerRecord: metadata.lastKnownServerRecord ) - } query: { - AncestorMetadata - .select(\.lastKnownServerRecord) - .where { $0.parentRecordName.is(nil) } - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension AncestorMetadata.Columns { - fileprivate init(_ metadata: SyncMetadata.TableColumns) { - self.init( - recordName: metadata.recordName, - parentRecordName: metadata.parentRecordName, - lastKnownServerRecord: metadata.lastKnownServerRecord - ) + } } -} #endif diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md index 0c9c0e7c..a492d29e 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md @@ -15,15 +15,7 @@ Info.plist with a value of `true`. This is subtly documented in [Apple's documen [Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic -- [Creating CKShare records](#Creating-CKShare-records) -- [Accepting shared records](#Accepting-shared-records) -- [Diving deeper into sharing](#Diving-deeper-into-sharing) - - [Sharing root records](#Sharing-root-records) - - [Sharing foreign key relationships](#Sharing-foreign-key-relationships) - - [One-to-many relationships](#One-to-many-relationships) - - [Many-to-many relationships](#Many-to-many-relationships) - - [One-to-"at most one" relationships](#One-to-at-most-one-relationships) -- [Controlling what data is shared](#Controlling-what-data-is-shared) +TODO: ToC ## Creating CKShare records @@ -344,6 +336,10 @@ graph BT Here the `CoverImage` table has a foreign key pointing to the root table `RemindersList`, but since it is also the primary key of the table it enforces that at most one cover image belongs to a list. +## Sharing permissions + +TODO: finish + ## Controlling what data is shared It is possible to specify that certain associations that are shareable not be shared. For example, diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 84099044..c68dc153 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -557,7 +557,8 @@ extension BaseCloudKitTests { [0]: "sqlitedata_icloud_datetime", [1]: "sqlitedata_icloud_diddelete", [2]: "sqlitedata_icloud_didupdate", - [3]: "sqlitedata_icloud_syncengineissynchronizingchanges" + [3]: "sqlitedata_icloud_haspermission", + [4]: "sqlitedata_icloud_syncengineissynchronizingchanges" ] """ } diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index 692c1d7b..2d679e4c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -758,5 +758,60 @@ extension BaseCloudKitTests { #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 3)) } } + + @Test func cascadingDeletes() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + RemindersList(id: 2, title: "Work") + Reminder(id: 2, title: "Call accountant", remindersListID: 2) + RemindersList(id: 3, title: "Secret") + Reminder(id: 3, title: "Schedule secret meeting", remindersListID: 3) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.where { $0.id <= 2 }.delete().execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 3, + isCompleted: 0, + remindersListID: 3, + title: "Schedule secret meeting" + ), + [1]: CKRecord( + recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 3, + title: "Secret" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 6da480a7..b580e573 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -89,6 +89,7 @@ extension BaseCloudKitTests { title: "Write blog post" ), share: nil, + _isDeleted: false, isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) ), @@ -114,6 +115,7 @@ extension BaseCloudKitTests { title: "Personal" ), share: nil, + _isDeleted: false, isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index aa6d0880..775e075b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -294,6 +294,7 @@ extension BaseCloudKitTests { title: "Personal" ), share: nil, + _isDeleted: false, isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 08aafd75..2118cd01 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -19,13 +19,13 @@ extension BaseCloudKitTests { [ [0]: """ CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" - AFTER DELETE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - SELECT sqlitedata_icloud_didDelete("old"."recordName", coalesce("old"."lastKnownServerRecord", ( + AFTER UPDATE OF "_isDeleted" ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN ((NOT ("old"."_isDeleted") AND "new"."_isDeleted") AND NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges())) BEGIN + SELECT sqlitedata_icloud_didDelete("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "old"."recordName") + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") UNION ALL SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" @@ -34,7 +34,7 @@ extension BaseCloudKitTests { SELECT "ancestorMetadatas"."lastKnownServerRecord" FROM "ancestorMetadatas" WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - ))); + )), "new"."share"); END """, [1]: """ @@ -54,13 +54,13 @@ extension BaseCloudKitTests { SELECT "ancestorMetadatas"."lastKnownServerRecord" FROM "ancestorMetadatas" WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - ))); + )), "new"."share"); END """, [2]: """ CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN (("old"."_isDeleted" = "new"."_isDeleted") AND NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges())) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" @@ -74,109 +74,373 @@ extension BaseCloudKitTests { SELECT "ancestorMetadatas"."lastKnownServerRecord" FROM "ancestorMetadatas" WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - ))); + )), "new"."share"); END """, [3]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_sync_engine" AFTER DELETE ON "childWithOnDeleteSetDefaults" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); END """, [4]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls" + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_user" + AFTER DELETE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [5]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_sync_engine" AFTER DELETE ON "childWithOnDeleteSetNulls" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); END """, - [5]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs" + [6]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_user" + AFTER DELETE ON "childWithOnDeleteSetNulls" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [7]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_sync_engine" AFTER DELETE ON "modelAs" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); END """, - [6]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs" + [8]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_user" + AFTER DELETE ON "modelAs" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [9]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_sync_engine" AFTER DELETE ON "modelBs" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); END """, - [7]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs" + [10]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_user" + AFTER DELETE ON "modelBs" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [11]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_sync_engine" AFTER DELETE ON "modelCs" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); END """, - [8]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents" + [12]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_user" + AFTER DELETE ON "modelCs" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [13]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_sync_engine" AFTER DELETE ON "parents" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); END """, - [9]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags" + [14]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_user" + AFTER DELETE ON "parents" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [15]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_sync_engine" AFTER DELETE ON "reminderTags" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); END """, - [10]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders" - AFTER DELETE ON "reminders" - FOR EACH ROW BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + [16]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_user" + AFTER DELETE ON "reminderTags" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); END """, - [11]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets" + [17]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_sync_engine" AFTER DELETE ON "remindersListAssets" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); END """, - [12]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates" + [18]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_user" + AFTER DELETE ON "remindersListAssets" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_sync_engine" AFTER DELETE ON "remindersListPrivates" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); END """, - [13]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists" + [20]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_user" + AFTER DELETE ON "remindersListPrivates" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [21]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_sync_engine" AFTER DELETE ON "remindersLists" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); END """, - [14]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags" + [22]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_user" + AFTER DELETE ON "remindersLists" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [23]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_sync_engine" + AFTER DELETE ON "reminders" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [24]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_user" + AFTER DELETE ON "reminders" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [25]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_sync_engine" AFTER DELETE ON "tags" - FOR EACH ROW BEGIN + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); END """, - [15]: """ + [26]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_user" + AFTER DELETE ON "tags" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [27]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' @@ -184,10 +448,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [16]: """ + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' @@ -195,10 +471,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [17]: """ + [29]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" AFTER INSERT ON "modelAs" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelAs', NULL, NULL @@ -206,10 +494,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [18]: """ + [30]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" AFTER INSERT ON "modelBs" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' @@ -217,10 +517,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [19]: """ + [31]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" AFTER INSERT ON "modelCs" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' @@ -228,10 +540,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [20]: """ + [32]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'parents', NULL, NULL @@ -239,10 +563,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [21]: """ + [33]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminderTags', NULL, NULL @@ -250,10 +586,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [22]: """ + [34]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' @@ -261,10 +609,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [23]: """ + [35]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" AFTER INSERT ON "remindersListAssets" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' @@ -272,10 +632,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [24]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' @@ -283,10 +655,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [25]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersLists', NULL, NULL @@ -294,10 +678,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [26]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" AFTER INSERT ON "tags" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."title", 'tags', NULL, NULL @@ -305,10 +701,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [27]: """ + [39]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' @@ -316,10 +724,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [28]: """ + [40]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' @@ -327,10 +747,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [29]: """ + [41]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" AFTER UPDATE ON "modelAs" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelAs', NULL, NULL @@ -338,10 +770,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [30]: """ + [42]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" AFTER UPDATE ON "modelBs" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' @@ -349,10 +793,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [31]: """ + [43]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" AFTER UPDATE ON "modelCs" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' @@ -360,10 +816,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [32]: """ + [44]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'parents', NULL, NULL @@ -371,10 +839,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [33]: """ + [45]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminderTags', NULL, NULL @@ -382,10 +862,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [34]: """ + [46]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' @@ -393,10 +885,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [35]: """ + [47]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' @@ -404,10 +908,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [36]: """ + [48]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' @@ -415,10 +931,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [37]: """ + [49]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersLists', NULL, NULL @@ -426,10 +954,22 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [38]: """ + [50]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."title", 'tags', NULL, NULL diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 76c74479..00b5419b 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -25,8 +25,6 @@ class BaseCloudKitTests: @unchecked Sendable { _syncEngine as! SyncEngine } - typealias SendablePrimaryKeyedTable = PrimaryKeyedTable & Sendable - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) init( accountStatus: CKAccountStatus = _AccountStatusScope.accountStatus, From 0738ccef98b1553a10f3b4edec43719ca919db8b Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 15 Aug 2025 21:21:42 -0500 Subject: [PATCH 485/581] Making sharing more testable (#128) * Beginnings of making sharing more testable. * wip * wip * wip * wip * fixes * wip --- .../CloudKit/CloudContainer.swift | 55 +++- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../CloudKit/CloudKitSharing.swift | 3 +- .../Internal/MockCloudContainer.swift | 48 +++- .../CloudKit/Internal/MockCloudDatabase.swift | 21 +- .../CloudKit/Internal/MockSyncEngine.swift | 22 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 23 +- .../CloudKit/SyncEngineProtocol.swift | 17 -- .../MockCloudDatabaseTests.swift | 22 ++ .../CloudKitTests/SharingTests.swift | 270 +++++++++++++++++- 10 files changed, 417 insertions(+), 66 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index c2bfc71e..5627daf5 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { associatedtype Database: CloudDatabase @@ -8,13 +9,42 @@ package protocol CloudContainer: AnyObject, Equatable, Hashable, Senda var containerIdentifier: String? { get } var rawValue: CKContainer { get } var privateCloudDatabase: Database { get } - func accept(_ metadata: CKShare.Metadata) async throws -> CKShare + func accept(_ metadata: ShareMetadata) async throws -> CKShare static func createContainer(identifier containerIdentifier: String) -> Self var sharedCloudDatabase: Database { get } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) - func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func shareMetadata(for share: CKShare, shouldFetchRootRecord: Bool) async throws -> ShareMetadata } +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) +package struct ShareMetadata: Hashable { + package var containerIdentifier: String + package var hierarchicalRootRecordID: CKRecord.ID? + package var rootRecord: CKRecord? + package var share: CKShare + package var rawValue: CKShare.Metadata? + package init(rawValue: CKShare.Metadata) { + self.containerIdentifier = rawValue.containerIdentifier + self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID + self.rootRecord = rawValue.rootRecord + self.share = rawValue.share + self.rawValue = rawValue + } + package init( + containerIdentifier: String, + hierarchicalRootRecordID: CKRecord.ID?, + rootRecord: CKRecord?, + share: CKShare + ) { + self.containerIdentifier = containerIdentifier + self.hierarchicalRootRecordID = hierarchicalRootRecordID + self.rootRecord = rootRecord + self.share = share + self.rawValue = nil + } +} + +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) extension CloudContainer { package func database(for recordID: CKRecord.ID) -> any CloudDatabase { recordID.zoneID.ownerName == CKCurrentUserDefaultName @@ -23,7 +53,16 @@ extension CloudContainer { } } +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) extension CKContainer: CloudContainer { + package func accept(_ metadata: ShareMetadata) async throws -> CKShare { + guard let metadata = metadata.rawValue + else { + fatalError("This should never be called with 'ShareMetadata' that has a nil 'rawValue'") + } + return try await self.accept(metadata) + } + package static func createContainer(identifier containerIdentifier: String) -> Self { Self(identifier: containerIdentifier) } @@ -32,16 +71,16 @@ extension CKContainer: CloudContainer { self } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) package func shareMetadata( - for url: URL, + for share: CKShare, shouldFetchRootRecord: Bool = false - ) async throws -> CKShare.Metadata { + ) async throws -> ShareMetadata { try await withUnsafeThrowingContinuation { continuation in - let operation = CKFetchShareMetadataOperation(shareURLs: [url]) + let operation = CKFetchShareMetadataOperation(shareURLs: [share.url].compactMap(\.self)) operation.shouldFetchRootRecord = true operation.perShareMetadataResultBlock = { url, result in - continuation.resume(with: result) + continuation.resume(with: result.map(ShareMetadata.init(rawValue:))) } add(operation) } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 5e47bcdf..40448072 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -243,7 +243,7 @@ extension CKRecord { let column = column as! any WritableTableColumnExpression let didSet: Bool if let value = other[key] as? CKAsset { - didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) + didSet = setValue(value, forKey: key, at: other[at: key]) } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) } else if other.encryptedValues[key] == nil { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index ed301c64..556b9b34 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -7,6 +7,7 @@ import SwiftUI import UIKit #endif +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) public struct SharedRecord: Hashable, Identifiable, Sendable { let container: any CloudContainer public let share: CKShare @@ -124,7 +125,7 @@ extension SyncEngine { let sharedRecord = try await existingShare ?? CKShare( rootRecord: rootRecord, shareID: CKRecord.ID( - recordName: UUID().uuidString, + recordName: "share-\(recordName)", zoneID: rootRecord.recordID.zoneID ) ) diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift index 56fe5bf9..39c11fe5 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift @@ -18,6 +18,12 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { self.containerIdentifier = containerIdentifier self.privateCloudDatabase = privateCloudDatabase self.sharedCloudDatabase = sharedCloudDatabase + + guard let containerIdentifier else { return } + @Dependency(\.mockCloudContainers) var mockCloudContainers + mockCloudContainers.withValue { storage in + storage[containerIdentifier] = self + } } package func accountStatus() -> CKAccountStatus { @@ -32,12 +38,39 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { _accountStatus.withValue { $0 } } - package func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata { - fatalError() + package func shareMetadata( + for share: CKShare, + shouldFetchRootRecord: Bool + ) async throws -> ShareMetadata { + let database = share.recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? privateCloudDatabase + : sharedCloudDatabase + + let rootRecord: CKRecord? = database.storage.withValue { + $0[share.recordID.zoneID]?.values.first { record in + record.share?.recordID == share.recordID + } + } + + return ShareMetadata( + containerIdentifier: containerIdentifier!, + hierarchicalRootRecordID: rootRecord?.recordID, + rootRecord: shouldFetchRootRecord ? rootRecord : nil, + share: share + ) } - package func accept(_ metadata: CKShare.Metadata) async throws -> CKShare { - fatalError() + package func accept(_ metadata: ShareMetadata) async throws -> CKShare { + guard let rootRecord = metadata.rootRecord + else { + fatalError("Must provide root record in mock shares during tests.") + } + + let (saveResults, _) = try sharedCloudDatabase.modifyRecords( + saving: [metadata.share, rootRecord] + ) + try saveResults.values.forEach { _ = try $0.get() } + return metadata.share } package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { @@ -45,7 +78,7 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { return mockCloudContainers.withValue { storage in let container: MockCloudContainer if let existingContainer = storage[containerIdentifier] { - container = existingContainer + return existingContainer } else { container = MockCloudContainer( accountStatus: .available, @@ -82,7 +115,10 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private enum MockCloudContainersKey: TestDependencyKey { +private enum MockCloudContainersKey: DependencyKey { + static var liveValue: LockIsolated<[String: MockCloudContainer]> { + LockIsolated<[String: MockCloudContainer]>([:]) + } static var testValue: LockIsolated<[String: MockCloudContainer]> { LockIsolated<[String: MockCloudContainer]>([:]) } diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift index 1fb82e7b..7bf55819 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -87,6 +87,22 @@ package final class MockCloudDatabase: CloudDatabase { switch savePolicy { case .ifServerRecordUnchanged: for recordToSave in recordsToSave { + if let share = recordToSave as? CKShare { + let isSavingRootRecord = recordsToSave.contains(where: { $0.share?.recordID == share.recordID }) + let shareWasPreviouslySaved = storage[share.recordID.zoneID]?[share.recordID] != nil + guard shareWasPreviouslySaved || isSavingRootRecord + else { + reportIssue( + """ + An added share is being saved without its rootRecord being saved in the same \ + operation. + """ + ) + saveResults[recordToSave.recordID] = .failure(CKError(.invalidArguments)) + continue + } + } + guard storage[recordToSave.recordID.zoneID] != nil else { saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) @@ -111,6 +127,7 @@ package final class MockCloudDatabase: CloudDatabase { guard let copy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } copy._recordChangeTag = UUID().uuidString + assets.withValue { assets in for key in copy.allKeys() { guard let assetURL = (copy[key] as? CKAsset)?.fileURL @@ -119,6 +136,8 @@ package final class MockCloudDatabase: CloudDatabase { .load(assetURL) } } + + // TODO: this should merge copy's values into storage but not sure how right now. storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy saveResults[recordToSave.recordID] = .success(copy) } @@ -268,7 +287,7 @@ extension MockCloudDatabase: CustomDumpReflectable { } } -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError { switch accountStatus { case .couldNotDetermine, .restricted, .noAccount: diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift index 3bd4507a..07bee97f 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift @@ -33,7 +33,27 @@ package final class MockSyncEngine: SyncEngineProtocol { } package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { - // TODO: do something here + let records: [CKRecord] + let zoneIDs: [CKRecordZone.ID] + switch options.scope { + case .all: + zoneIDs = Array(database.storage.keys) + case .allExcluding(let excludedZoneIDs): + zoneIDs = Array(Set(database.storage.keys).subtracting(excludedZoneIDs)) + case .zoneIDs(let includedZoneIDs): + zoneIDs = includedZoneIDs + @unknown default: + fatalError() + } + records = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in + accum += database.storage.withValue { + ($0[zoneID]?.values).map { Array($0) } ?? [] + } + } + await delegate.handleEvent( + .fetchedRecordZoneChanges(modifications: records, deletions: []), + syncEngine: self + ) } package func recordZoneChangeBatch( diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 005b4794..92072d43 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -478,22 +478,16 @@ syncEngine?.state.add(pendingRecordZoneChanges: changes) } - // TODO: Possible to get test coverage on this? package func acceptShare(metadata: ShareMetadata) async throws { - guard let metadata = metadata.rawValue - else { - reportIssue("TODO") - return - } guard let rootRecordID = metadata.hierarchicalRootRecordID else { - reportIssue("TODO") + reportIssue("Attempting to share without root record information.") return } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) _ = try await container.accept(metadata) try await syncEngines.shared?.fetchChanges( - .init( + CKSyncEngine.FetchChangesOptions( scope: .zoneIDs([rootRecordID.zoneID]), operationGroup: nil ) @@ -1253,25 +1247,18 @@ } private func cacheShare(_ share: CKShare) async throws { - // TODO: Instead of getting URL here we can make `shareMetadata(…)` take a share instead of a URL - guard let url = share.url - else { return } - guard - let metadata = try? await container.shareMetadata( - for: url, - shouldFetchRootRecord: true - ) + let metadata = try? await container.shareMetadata(for: share, shouldFetchRootRecord: false) else { // TODO: should we delete this record if it doesn't exist in the container? return } - guard let rootRecord = metadata.rootRecord + guard let rootRecordID = metadata.hierarchicalRootRecordID else { return } try await userDatabase.write { db in try SyncMetadata - .where { $0.recordName.eq(rootRecord.recordID.recordName) } + .where { $0.recordName.eq(rootRecordID.recordName) } .update { $0.share = share } .execute(db) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index fbb0426d..1ddc0b7a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -27,23 +27,6 @@ package protocol SyncEngineProtocol: AnyObject, Sendable { ) async -> CKSyncEngine.RecordZoneChangeBatch? } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package struct ShareMetadata: Hashable { - package var containerIdentifier: String - package var hierarchicalRootRecordID: CKRecord.ID? - package var rawValue: CKShare.Metadata? - package init(rawValue: CKShare.Metadata) { - self.containerIdentifier = rawValue.containerIdentifier - self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID - self.rawValue = rawValue - } - package init(containerIdentifier: String, hierarchicalRootRecordID: CKRecord.ID?) { - self.containerIdentifier = containerIdentifier - self.hierarchicalRootRecordID = hierarchicalRootRecordID - self.rawValue = nil - } -} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package protocol CKSyncEngineStateProtocol: Sendable { var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index 31822f41..5b94aa00 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -388,5 +388,27 @@ extension BaseCloudKitTests { """ } } + + @Test func saveShareWithoutRootRecord() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + try withKnownIssue { + _ = try syncEngine.modifyRecords(scope: .private, saving: [share]) + } matching: { issue in + issue.description == """ + Issue recorded: An added share is being saved without its rootRecord being saved in the \ + same operation. + """ + } + } + + @Test func saveShareAndRootThenSaveShareAlone() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [share, record]) + + let newShare = try syncEngine.private.database.record(for: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [newShare]) + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 775e075b..0d599b0f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -61,7 +61,10 @@ extension BaseCloudKitTests { configure: { _ in } ) } - assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { """ "The record could not be shared." """ @@ -86,7 +89,10 @@ extension BaseCloudKitTests { configure: { _ in } ) } - assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { """ "The record could not be shared." """ @@ -122,7 +128,10 @@ extension BaseCloudKitTests { configure: { _ in } ) } - assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { """ "The record could not be shared." """ @@ -226,13 +235,19 @@ extension BaseCloudKitTests { let share = CKShare( rootRecord: remindersListRecord, shareID: CKRecord.ID( - recordName: "Share-\(1)", + recordName: "share-\(remindersListRecord.recordID.recordName)", zoneID: externalZone.zoneID ) ) - try await syncEngine.modifyRecords(scope: .shared, saving: [share]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + + let newShare = try syncEngine.shared.database.record(for: share.recordID) + let newRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + try await syncEngine.modifyRecords(scope: .shared, saving: [newShare]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [newRemindersListRecord]).notify() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ @@ -245,7 +260,7 @@ extension BaseCloudKitTests { databaseScope: .shared, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(Share-1/external.zone/external.owner), + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), recordType: "cloudkit.share", parent: nil, share: nil @@ -254,7 +269,7 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), id: 1, isCompleted: 0, title: "Personal" @@ -282,20 +297,25 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)) + 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/external.zone/external.owner)), + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), id: 1, isCompleted: 0, title: "Personal" ), - share: nil, + share: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), _isDeleted: false, - isShared: false, + isShared: true, userModificationDate: Date(1970-01-01T00:00:00.000Z) ) ] @@ -402,7 +422,10 @@ extension BaseCloudKitTests { reminderRecord.setValue(1, forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord, reminderRecord]).notify() + try await syncEngine.modifyRecords( + scope: .shared, + saving: [remindersListRecord, reminderRecord] + ).notify() try await withDependencies { $0.datetime.now.addTimeInterval(60) @@ -437,6 +460,227 @@ extension BaseCloudKitTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func share() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let sharedRecord = 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) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShare() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.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)" + ) + } + + 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" + ) + ] + ) + ) + """ + } + } + + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShareCreateReminder() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + 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") + } + + 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" + ) + ] + ) + ) + """ + } + } } } From 0f6e7c4d70181395d85307be7e107b9d17bcfe23 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Mon, 18 Aug 2025 15:34:57 -0500 Subject: [PATCH 486/581] More sharing tests and improvements (#129) * Fixing more sharing edge cases with tests. * wip * wip * wip * wip * dont emit error when unsharing unshared record. * wip * fix --------- Co-authored-by: Stephen Celis --- .../CloudKit/CloudKitSharing.swift | 452 ++++++++-------- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 4 +- .../SharingGRDBCore/CloudKit/Triggers.swift | 3 +- .../CloudKitTests/AccountLifecycleTests.swift | 2 +- .../CloudKitTests/AssetsTests.swift | 4 +- .../CloudKitTests/CloudKitTests.swift | 26 +- .../FetchRecordZoneChangesTests.swift | 8 +- .../FetchedDatabaseChangesTests.swift | 2 +- .../ForeignKeyConstraintTests.swift | 18 +- .../CloudKitTests/MergeConflictTests.swift | 22 +- .../CloudKitTests/MetadataTests.swift | 6 +- .../MockCloudDatabaseTests.swift | 14 +- .../CloudKitTests/NewTableSyncTests.swift | 2 +- .../NextRecordZoneChangeBatchTests.swift | 12 +- .../ReferenceViolationTests.swift | 10 +- .../CloudKitTests/SharingTests.swift | 492 +++++++++++++++++- .../CloudKitTests/TriggerTests.swift | 72 +-- Tests/SharingGRDBTests/Internal/Schema.swift | 3 - 18 files changed, 833 insertions(+), 319 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 556b9b34..50dd1928 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -1,245 +1,279 @@ #if canImport(CloudKit) -import CloudKit -import Dependencies -import SwiftUI + import CloudKit + import Dependencies + import SwiftUI -#if canImport(UIKit) - import UIKit -#endif - -@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) -public struct SharedRecord: Hashable, Identifiable, Sendable { - let container: any CloudContainer - public let share: CKShare + #if canImport(UIKit) + import UIKit + #endif - public var id: CKRecord.ID { share.recordID } + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + public struct SharedRecord: Hashable, Identifiable, Sendable { + let container: any CloudContainer + public let share: CKShare - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.container === rhs.container && lhs.share == rhs.share - } + public var id: CKRecord.ID { share.recordID } - public func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(container)) - hasher.combine(share) - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine { - private struct SharingError: LocalizedError { - enum Reason { - case recordMetadataNotFound - case recordNotRoot([ForeignKey]) - case recordTableNotSynchronized - case recordTablePrivate + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.container === rhs.container && lhs.share == rhs.share } - let recordTableName: String - let recordPrimaryKey: String - let reason: Reason - let debugDescription: String - - var errorDescription: String? { - "The record could not be shared." + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(container)) + hasher.combine(share) } } - public func share( - record: T, - configure: @Sendable (CKShare) -> Void - ) async throws -> SharedRecord - where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { - guard tablesByName[T.tableName] != nil - else { - throw SharingError( - recordTableName: T.tableName, - recordPrimaryKey: record.primaryKey.rawIdentifier, - reason: .recordTableNotSynchronized, - debugDescription: """ - Table is not shareable: table type not passed to 'tables' parameter of 'SyncEngine.init'. - """ - ) - } - if let foreignKeys = foreignKeysByTableName[T.tableName], !foreignKeys.isEmpty { - throw SharingError( - recordTableName: T.tableName, - recordPrimaryKey: record.primaryKey.rawIdentifier, - reason: .recordNotRoot(foreignKeys), - debugDescription: """ - Only root records are shareable, but parent record(s) detected via foreign key(s). - """ - ) - } - guard !privateTables.contains(where: { T.self == $0 }) - else { - throw SharingError( - recordTableName: T.tableName, - recordPrimaryKey: record.primaryKey.rawIdentifier, - reason: .recordTablePrivate, - debugDescription: """ - Private tables are not shareable: table type passed to 'privateTables' parameter of \ - 'SyncEngine.init'. - """ - ) - } - let recordName = record.recordName - let metadata = - try await metadatabase.read { db in - try SyncMetadata - .where { $0.recordName.eq(recordName) } - .fetchOne(db) - } ?? nil - guard let metadata - else { - throw SharingError( - recordTableName: T.tableName, - recordPrimaryKey: record.primaryKey.rawIdentifier, - reason: .recordMetadataNotFound, - debugDescription: """ - No sync metadata found for record. Has the record been saved to the database? - """ - ) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + private struct SharingError: LocalizedError { + enum Reason { + case recordMetadataNotFound + case recordNotRoot([ForeignKey]) + case recordTableNotSynchronized + case recordTablePrivate + } - let rootRecord = - metadata.lastKnownServerRecord - ?? CKRecord( - recordType: metadata.recordType, - recordID: CKRecord.ID(recordName: metadata.recordName, zoneID: defaultZone.zoneID) - ) + let recordTableName: String + let recordPrimaryKey: String + let reason: Reason + let debugDescription: String - var existingShare: CKShare? { - get async throws { - guard let shareRecordID = rootRecord.share?.recordID - else { return nil } - do { - return try await container.database(for: rootRecord.recordID) - .record(for: shareRecordID) as? CKShare - } catch let error as CKError where error.code == .unknownItem { - reportIssue("This would have been a problem before") - return nil - } + var errorDescription: String? { + "The record could not be shared." } } - let sharedRecord = try await existingShare ?? CKShare( - rootRecord: rootRecord, - shareID: CKRecord.ID( - recordName: "share-\(recordName)", - zoneID: rootRecord.recordID.zoneID - ) - ) - - configure(sharedRecord) - // TODO: We are getting an "client oplock error updating record" error in the logs when - // creating new shares / editing existing shares. - _ = try await container.privateCloudDatabase.modifyRecords( - saving: [sharedRecord, rootRecord], - deleting: [] - ) - try await userDatabase.write { db in - try SyncMetadata - .where { $0.recordName.eq(recordName) } - .update { $0.share = sharedRecord } - .execute(db) - } + public func share( + record: T, + configure: @Sendable (CKShare) -> Void + ) async throws -> SharedRecord + where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { + guard tablesByName[T.tableName] != nil + else { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordTableNotSynchronized, + debugDescription: """ + Table is not shareable: table type not passed to 'tables' parameter of 'SyncEngine.init'. + """ + ) + } + if let foreignKeys = foreignKeysByTableName[T.tableName], !foreignKeys.isEmpty { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordNotRoot(foreignKeys), + debugDescription: """ + Only root records are shareable, but parent record(s) detected via foreign key(s). + """ + ) + } + guard !privateTables.contains(where: { T.self == $0 }) + else { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordTablePrivate, + debugDescription: """ + Private tables are not shareable: table type passed to 'privateTables' parameter of \ + 'SyncEngine.init'. + """ + ) + } + let recordName = record.recordName + let metadata = + try await metadatabase.read { db in + try SyncMetadata + .where { $0.recordName.eq(recordName) } + .fetchOne(db) + } ?? nil + guard let metadata + else { + throw SharingError( + recordTableName: T.tableName, + recordPrimaryKey: record.primaryKey.rawIdentifier, + reason: .recordMetadataNotFound, + debugDescription: """ + No sync metadata found for record. Has the record been saved to the database? + """ + ) + } - return SharedRecord(container: container, share: sharedRecord) - } + let rootRecord = + metadata.lastKnownServerRecord + ?? CKRecord( + recordType: metadata.recordType, + recordID: CKRecord.ID(recordName: metadata.recordName, zoneID: defaultZone.zoneID) + ) - public func acceptShare(metadata: CKShare.Metadata) async throws { - try await acceptShare(metadata: ShareMetadata(rawValue: metadata)) - } -} - -#if canImport(UIKit) && !os(watchOS) - @available(iOS 17, macOS 14, tvOS 17, *) - public struct CloudSharingView: UIViewControllerRepresentable { - let sharedRecord: SharedRecord - let availablePermissions: UICloudSharingController.PermissionOptions - let didFinish: (Result) -> Void - let didStopSharing: () -> Void - public init(sharedRecord: SharedRecord, availablePermissions: UICloudSharingController.PermissionOptions = []) { - self.init(sharedRecord: sharedRecord, availablePermissions: availablePermissions, didFinish: { _ in }, didStopSharing: {}) - } - public init( - sharedRecord: SharedRecord, - availablePermissions: UICloudSharingController.PermissionOptions = [], - didFinish: @escaping (Result) -> Void, - didStopSharing: @escaping () -> Void - ) { - self.sharedRecord = sharedRecord - self.didFinish = didFinish - self.didStopSharing = didStopSharing - self.availablePermissions = availablePermissions - } + var existingShare: CKShare? { + get async throws { + guard let shareRecordID = rootRecord.share?.recordID + else { return nil } + do { + return try await container.database(for: rootRecord.recordID) + .record(for: shareRecordID) as? CKShare + } catch let error as CKError where error.code == .unknownItem { + reportIssue("This would have been a problem before") + return nil + } + } + } + + let sharedRecord = + try await existingShare + ?? CKShare( + rootRecord: rootRecord, + shareID: CKRecord.ID( + recordName: "share-\(recordName)", + zoneID: rootRecord.recordID.zoneID + ) + ) - public func makeCoordinator() -> CloudSharingDelegate { - CloudSharingDelegate( - share: sharedRecord.share, - didFinish: didFinish, - didStopSharing: didStopSharing + configure(sharedRecord) + // TODO: We are getting an "client oplock error updating record" error in the logs when + // creating new shares / editing existing shares. + _ = try await container.privateCloudDatabase.modifyRecords( + saving: [sharedRecord, rootRecord], + deleting: [] ) + try await userDatabase.write { db in + try SyncMetadata + .where { $0.recordName.eq(recordName) } + .update { $0.share = sharedRecord } + .execute(db) + } + + return SharedRecord(container: container, share: sharedRecord) } - public func makeUIViewController(context: Context) -> UICloudSharingController { - let controller = UICloudSharingController( - share: sharedRecord.share, - container: sharedRecord.container.rawValue + public func unshare(record: T) async throws + where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { + let share = try await userDatabase.read { [recordName = record.recordName] db in + try SyncMetadata + .where { $0.recordName.eq(recordName) } + .select(\.share) + .fetchOne(db) + ?? nil + } + guard let share + else { + reportIssue(""" + No share found associated with record. + """) + return + } + + let result = try await syncEngines.private?.database.modifyRecords( + saving: [], + deleting: [share.recordID] ) - controller.delegate = context.coordinator - controller.availablePermissions = availablePermissions - return controller + try result?.deleteResults.values.forEach { _ = try $0.get() } } - public func updateUIViewController( - _ uiViewController: UICloudSharingController, - context: Context - ) { + public func acceptShare(metadata: CKShare.Metadata) async throws { + try await acceptShare(metadata: ShareMetadata(rawValue: metadata)) } } - @available(iOS 17, macOS 14, tvOS 17, *) - public final class CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { - let share: CKShare - let didFinish: (Result) -> Void - let didStopSharing: () -> Void - init( - share: CKShare, - didFinish: @escaping (Result) -> Void, - didStopSharing: @escaping () -> Void - ) { - self.share = share - self.didFinish = didFinish - self.didStopSharing = didStopSharing - } + #if canImport(UIKit) && !os(watchOS) + @available(iOS 17, macOS 14, tvOS 17, *) + public struct CloudSharingView: UIViewControllerRepresentable { + let sharedRecord: SharedRecord + let availablePermissions: UICloudSharingController.PermissionOptions + let didFinish: (Result) -> Void + let didStopSharing: () -> Void + public init( + sharedRecord: SharedRecord, + availablePermissions: UICloudSharingController.PermissionOptions = [] + ) { + self.init( + sharedRecord: sharedRecord, + availablePermissions: availablePermissions, + didFinish: { _ in }, + didStopSharing: {} + ) + } + public init( + sharedRecord: SharedRecord, + availablePermissions: UICloudSharingController.PermissionOptions = [], + didFinish: @escaping (Result) -> Void, + didStopSharing: @escaping () -> Void + ) { + self.sharedRecord = sharedRecord + self.didFinish = didFinish + self.didStopSharing = didStopSharing + self.availablePermissions = availablePermissions + } - public func itemThumbnailData(for csc: UICloudSharingController) -> Data? { - share[CKShare.SystemFieldKey.thumbnailImageData] as? Data - } + public func makeCoordinator() -> CloudSharingDelegate { + CloudSharingDelegate( + share: sharedRecord.share, + didFinish: didFinish, + didStopSharing: didStopSharing + ) + } - public func itemTitle(for csc: UICloudSharingController) -> String? { - share[CKShare.SystemFieldKey.title] as? String - } + public func makeUIViewController(context: Context) -> UICloudSharingController { + let controller = UICloudSharingController( + share: sharedRecord.share, + container: sharedRecord.container.rawValue + ) + controller.delegate = context.coordinator + controller.availablePermissions = availablePermissions + return controller + } - public func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) { - didFinish(.success(())) + public func updateUIViewController( + _ uiViewController: UICloudSharingController, + context: Context + ) { + } } - public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { - @Dependency(\.defaultSyncEngine) var syncEngine - withErrorReporting { - try syncEngine.deleteShare(recordID: share.recordID) + @available(iOS 17, macOS 14, tvOS 17, *) + public final class CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { + let share: CKShare + let didFinish: (Result) -> Void + let didStopSharing: () -> Void + init( + share: CKShare, + didFinish: @escaping (Result) -> Void, + didStopSharing: @escaping () -> Void + ) { + self.share = share + self.didFinish = didFinish + self.didStopSharing = didStopSharing + } + + public func itemThumbnailData(for csc: UICloudSharingController) -> Data? { + share[CKShare.SystemFieldKey.thumbnailImageData] as? Data + } + + public func itemTitle(for csc: UICloudSharingController) -> String? { + share[CKShare.SystemFieldKey.title] as? String } - didStopSharing() - } - public func cloudSharingController( - _ csc: UICloudSharingController, - failedToSaveShareWithError error: any Error - ) { - didFinish(.failure(error)) + public func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) { + didFinish(.success(())) + } + + public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { + @Dependency(\.defaultSyncEngine) var syncEngine + withErrorReporting { + try syncEngine.deleteShare(recordID: share.recordID) + } + didStopSharing() + } + + public func cloudSharingController( + _ csc: UICloudSharingController, + failedToSaveShareWithError error: any Error + ) { + didFinish(.failure(error)) + } } - } -#endif + #endif #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 92072d43..b0d8ea73 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1006,8 +1006,8 @@ } open(table) } else if recordType == CKRecord.SystemType.share { - withErrorReporting { - for recordID in recordIDs { + for recordID in recordIDs { + withErrorReporting { try deleteShare(recordID: recordID) } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 65f11912..099275d4 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -238,7 +238,8 @@ ) } .where { - $0.parentRecordName.is(nil) + !SyncEngine.isSynchronizingChanges() + && $0.parentRecordName.is(nil) && !SQLQueryExpression( "\(raw: String.sqliteDataCloudKitSchemaName)_hasPermission(\($0.share))" ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift index 885b1e0e..ebe46262 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift @@ -59,7 +59,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingDatabaseChanges(scope: .private) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index 0fd6da2c..41fefae0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -26,7 +26,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -82,7 +82,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index c68dc153..13559069 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -460,7 +460,7 @@ extension BaseCloudKitTests { } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -506,7 +506,7 @@ extension BaseCloudKitTests { } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -587,7 +587,7 @@ extension BaseCloudKitTests { .execute(db) } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -622,7 +622,7 @@ extension BaseCloudKitTests { } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -653,7 +653,7 @@ extension BaseCloudKitTests { .execute(db) } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -677,7 +677,7 @@ extension BaseCloudKitTests { } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -728,7 +728,7 @@ extension BaseCloudKitTests { } ) #expect(metadata.userModificationDate == serverModificationDate) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -773,7 +773,7 @@ extension BaseCloudKitTests { let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -806,7 +806,7 @@ extension BaseCloudKitTests { } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -856,7 +856,7 @@ extension BaseCloudKitTests { } ) #expect(metadata.userModificationDate == userModificationDate) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -889,7 +889,7 @@ extension BaseCloudKitTests { } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -926,7 +926,7 @@ extension BaseCloudKitTests { .fetchOne(db) } #expect(metadata == nil) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -976,7 +976,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index c3051a28..929d7037 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -211,7 +211,7 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]).notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -254,7 +254,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) } - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -310,7 +310,7 @@ extension BaseCloudKitTests { ) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -375,7 +375,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) } - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift index 2e9fd4af..e13f3781 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift @@ -74,7 +74,7 @@ extension BaseCloudKitTests { try #expect(UnsyncedModel.count().fetchOne(db) == 2) } - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index 2d679e4c..bf09dd5b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -35,7 +35,7 @@ extension BaseCloudKitTests { ) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -107,7 +107,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) } - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -184,7 +184,7 @@ extension BaseCloudKitTests { ) } - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -246,7 +246,7 @@ extension BaseCloudKitTests { ) .notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -316,7 +316,7 @@ extension BaseCloudKitTests { _ = try { try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) }() try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -397,7 +397,7 @@ extension BaseCloudKitTests { try await relaunchedSyncEngine.processPendingRecordZoneChanges(scope: .private) } - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -474,7 +474,7 @@ extension BaseCloudKitTests { saving: [reminderRecord, personalListRecord] ).notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -526,7 +526,7 @@ extension BaseCloudKitTests { await modifications.notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -779,7 +779,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 3416b803..36495d03 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -18,7 +18,7 @@ extension BaseCloudKitTests { } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -76,7 +76,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -121,7 +121,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) await modificationCallback.notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -172,7 +172,7 @@ extension BaseCloudKitTests { } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -230,7 +230,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -275,7 +275,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) await modificationCallback.notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -344,7 +344,7 @@ extension BaseCloudKitTests { await modificationCallback.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -420,7 +420,7 @@ extension BaseCloudKitTests { ) } - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -489,7 +489,7 @@ extension BaseCloudKitTests { await modificationCallback.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -559,7 +559,7 @@ extension BaseCloudKitTests { await modificationCallback.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -636,7 +636,7 @@ extension BaseCloudKitTests { await modificationsFinished.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index 9470bf6f..aebbc811 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -21,7 +21,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -91,7 +91,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -145,7 +145,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index 5b94aa00..7751074d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -56,7 +56,7 @@ extension BaseCloudKitTests { ) #expect(saveRecordResults.allSatisfy({ (try? $1.get()) != nil })) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -102,7 +102,7 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .private, saving: [child]).notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -134,7 +134,7 @@ extension BaseCloudKitTests { } #expect(error == CKError(.zoneNotFound)) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -162,7 +162,7 @@ extension BaseCloudKitTests { ) #expect(deleteResults.allSatisfy({ (try? $1.get()) != nil })) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -187,7 +187,7 @@ extension BaseCloudKitTests { ) #expect(deleteResults.allSatisfy({ (try? $1.get()) != nil })) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -218,7 +218,7 @@ extension BaseCloudKitTests { } #expect(error == CKError(.zoneNotFound)) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -249,7 +249,7 @@ extension BaseCloudKitTests { } #expect(error == CKError(.referenceViolation)) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index b580e573..387cac19 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -25,7 +25,7 @@ extension BaseCloudKitTests { @Test func initialSync() async throws { try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 8b688a13..b8b23725 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -15,7 +15,7 @@ extension BaseCloudKitTests { ) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -44,7 +44,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -73,7 +73,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -97,7 +97,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -132,7 +132,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -176,7 +176,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index 47aeff38..23770546 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -41,7 +41,7 @@ extension BaseCloudKitTests { await modifications.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -115,7 +115,7 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) await modifications.notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -199,7 +199,7 @@ extension BaseCloudKitTests { await modifications.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -277,7 +277,7 @@ extension BaseCloudKitTests { await modifications.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -357,7 +357,7 @@ extension BaseCloudKitTests { await modifications.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 0d599b0f..58c7e2a1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -178,7 +178,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -249,7 +249,7 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .shared, saving: [newShare]).notify() try await syncEngine.modifyRecords(scope: .shared, saving: [newRemindersListRecord]).notify() - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -353,7 +353,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -436,7 +436,7 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -511,6 +511,93 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func unshareNonSharedRecord() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withKnownIssue { + try await syncEngine.unshare(record: remindersList) + } matching: { issue in + issue.description == """ + Issue recorded: No share found associated with record. + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareUnshareShareAgain() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + + try await syncEngine.unshare(record: remindersList) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func acceptShare() async throws { let externalZone = CKRecordZone( @@ -589,7 +676,6 @@ extension BaseCloudKitTests { } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func acceptShareCreateReminder() async throws { let externalZone = CKRecordZone( @@ -681,6 +767,402 @@ extension BaseCloudKitTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRootSharedRecord_CurrentUserOwnsRecord() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try #expect(RemindersList.all.fetchCount(db) == 0) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + /// Deleting a root shared record that is not owned by current user should only delete + /// the CKShare but not the actual records. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRootSharedRecord_CurrentUserNotOwner() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + 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) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: 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" + ), + [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" + ) + ] + ) + ) + """ + } + } + + /// Inserting record into shared record when user does not have permission should be rejected. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertRecordInReadOnlyRemindersList() 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 + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.all.fetchCount(db) == 0) + } + 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" + ) + ] + ) + ) + """ + } + } + + /// Delete record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteReminderInReadOnlyRemindersList() 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 + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + try await syncEngine.modifyRecords(scope: .shared, saving: [reminderRecord]).notify() + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.find(1).delete().execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.count().fetchOne(db) == 1) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + 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" + ) + ] + ) + ) + """ + } + } + + /// Editing record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editReminderInReadOnlyRemindersList() 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 + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.setValue(false, forKey: "isCompleted", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + try await syncEngine.modifyRecords(scope: .shared, saving: [reminderRecord]).notify() + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.update { $0.isCompleted = true }.execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.where(\.isCompleted).fetchCount(db) == 0) + } + 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" + ) + ] + ) + ) + """ + } + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index 2118cd01..a7baa5a1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -100,7 +100,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); @@ -129,7 +129,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); @@ -158,7 +158,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); @@ -187,7 +187,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); @@ -216,7 +216,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); @@ -245,7 +245,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); @@ -274,7 +274,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); @@ -303,7 +303,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); @@ -332,7 +332,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); @@ -361,7 +361,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); @@ -390,7 +390,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); @@ -419,7 +419,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); @@ -440,7 +440,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' @@ -463,7 +463,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' @@ -486,7 +486,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelAs', NULL, NULL @@ -509,7 +509,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' @@ -532,7 +532,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' @@ -555,7 +555,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'parents', NULL, NULL @@ -578,7 +578,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminderTags', NULL, NULL @@ -601,7 +601,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' @@ -624,7 +624,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' @@ -647,7 +647,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' @@ -670,7 +670,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersLists', NULL, NULL @@ -693,7 +693,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."title", 'tags', NULL, NULL @@ -716,7 +716,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' @@ -739,7 +739,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' @@ -762,7 +762,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelAs', NULL, NULL @@ -785,7 +785,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' @@ -808,7 +808,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' @@ -831,7 +831,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'parents', NULL, NULL @@ -854,7 +854,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminderTags', NULL, NULL @@ -877,7 +877,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' @@ -900,7 +900,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' @@ -923,7 +923,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' @@ -946,7 +946,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersLists', NULL, NULL @@ -969,7 +969,7 @@ extension BaseCloudKitTests { ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."title", 'tags', NULL, NULL diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index b499eced..093c7aa9 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -75,9 +75,6 @@ func database(containerIdentifier: String) throws -> DatabasePool { var configuration = Configuration() configuration.prepareDatabase { db in try db.attachMetadatabase(containerIdentifier: containerIdentifier) - db.trace { - print($0.expandedDescription) - } } let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") let database = try DatabasePool(path: url.path(), configuration: configuration) From 0a3762aced9b81b57847abb04cd617c8e983d9c2 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:25:45 -0500 Subject: [PATCH 487/581] Test for generated column behavior. (#136) * Test for generated column behavior. * snap --- .../CloudKitTests/CloudKitTests.swift | 70 ++++++++++++++++++- .../CloudKitTests/RecordTypeTests.swift | 3 +- .../CloudKitTests/UserlandTests.swift | 2 +- Tests/SharingGRDBTests/Internal/Schema.swift | 5 +- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 13559069..06757dc4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -344,7 +344,8 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) """, tableInfo: [ @@ -1009,5 +1010,72 @@ extension BaseCloudKitTests { } } + @Test func generatedColumns() async throws { + try await userDatabase.userWrite { db in + try db.seed { + ModelA(id: 1, count: 42, isEven: true) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil, + count: 42, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + + let record = try syncEngine.private.database.record(for: ModelA.recordID(for: 1)) + record.encryptedValues["isEven"] = false + try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil, + count: 42, + id: 1, + isEven: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.read { db in + let modelA = try #require(try ModelA.find(1).fetchOne(db)) + #expect(modelA.isEven == true) + } + } + // TODO: Test what happens when we delete locally and then an edit comes in from the server } diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index dabfa903..44272b58 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -343,7 +343,8 @@ extension BaseCloudKitTests { schema: """ CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) """, tableInfo: [ diff --git a/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift b/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift index 988cb476..7a1ba9cd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift @@ -25,7 +25,7 @@ import SharingGRDB } } try await $modelAs.load() - #expect(modelAs == [ModelA(id: 1)]) + #expect(modelAs == [ModelA(id: 1, isEven: true)]) } } } diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 093c7aa9..2022a0e2 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -55,6 +55,8 @@ import SharingGRDB @Table struct ModelA: Equatable, Identifiable { let id: Int var count = 0 + @Column(generated: .virtual) + let isEven: Bool } @Table struct ModelB: Equatable, Identifiable { let id: Int @@ -175,7 +177,8 @@ func database(containerIdentifier: String) throws -> DatabasePool { try #sql(""" CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) """) .execute(db) From b94fca6c210d478d3a31d729f984342dd3b15064 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 11:20:21 -0700 Subject: [PATCH 488/581] wip --- Package.swift.orig | 138 --------------------------------------------- 1 file changed, 138 deletions(-) delete mode 100644 Package.swift.orig diff --git a/Package.swift.orig b/Package.swift.orig deleted file mode 100644 index 2d87683e..00000000 --- a/Package.swift.orig +++ /dev/null @@ -1,138 +0,0 @@ -// swift-tools-version: 6.1 - -import PackageDescription - -let package = Package( - name: "sharing-grdb", - platforms: [ - .iOS(.v13), - .macOS(.v10_15), - .tvOS(.v13), - .watchOS(.v7), - ], - products: [ - .library( - name: "SharingGRDB", - targets: ["SharingGRDB"] - ), - .library( - name: "SharingGRDBCore", - targets: ["SharingGRDBCore"] - ), - .library( - name: "StructuredQueriesGRDB", - targets: ["StructuredQueriesGRDB"] - ), - .library( - name: "StructuredQueriesGRDBCore", - targets: ["StructuredQueriesGRDBCore"] - ), - ], - traits: [ - .trait( - name: "SharingGRDBTagged", - description: "Introduce SharingGRDB conformances to the swift-tagged package." - ), - .default(enabledTraits: ["SharingGRDBTagged"]), - ], - dependencies: [ - .package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"), - .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), - .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), -<<<<<<< HEAD - .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.0.0"), - .package( - url: "https://github.com/pointfreeco/swift-structured-queries", - from: "0.12.1", - traits: [ - .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SharingGRDBTagged"])), - ] - ), - .package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.10.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), -======= - .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.13.0"), ->>>>>>> origin/main - ], - targets: [ - .target( - name: "SharingGRDB", - dependencies: [ - "SharingGRDBCore", - "StructuredQueriesGRDB", - ] - ), - .target( - name: "SharingGRDBCore", - dependencies: [ - "StructuredQueriesGRDBCore", - .product(name: "GRDB", package: "GRDB.swift"), - .product(name: "OrderedCollections", package: "swift-collections"), - .product(name: "Sharing", package: "swift-sharing"), - .product(name: "StructuredQueriesCore", package: "swift-structured-queries"), - .product( - name: "Tagged", - package: "swift-tagged", - condition: .when(traits: ["SharingGRDBTagged"]) - ), - ] - ), - .testTarget( - name: "SharingGRDBTests", - dependencies: [ - "SharingGRDB", - .product(name: "DependenciesTestSupport", package: "swift-dependencies"), - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), - .product(name: "StructuredQueries", package: "swift-structured-queries"), - ] - ), - .target( - name: "StructuredQueriesGRDBCore", - dependencies: [ - .product(name: "GRDB", package: "GRDB.swift"), - .product(name: "Dependencies", package: "swift-dependencies"), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), - .product(name: "StructuredQueriesCore", package: "swift-structured-queries"), - ] - ), - .target( - name: "StructuredQueriesGRDB", - dependencies: [ - "StructuredQueriesGRDBCore", - .product(name: "StructuredQueries", package: "swift-structured-queries"), - ] - ), - .testTarget( - name: "StructuredQueriesGRDBTests", - dependencies: [ - "StructuredQueriesGRDB", - .product(name: "DependenciesTestSupport", package: "swift-dependencies"), - .product(name: "StructuredQueries", package: "swift-structured-queries"), - ] - ), - ], - swiftLanguageModes: [.v6] -) - -let swiftSettings: [SwiftSetting] = [ - .enableUpcomingFeature("MemberImportVisibility"), - // .unsafeFlags([ - // "-Xfrontend", - // "-warn-long-function-bodies=50", - // "-Xfrontend", - // "-warn-long-expression-type-checking=50", - // ]) -] - -for index in package.targets.indices { - package.targets[index].swiftSettings = swiftSettings -} - -#if !os(Windows) - // Add the documentation compiler plugin if possible - package.dependencies.append( - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") - ) -#endif From 77f841922834b99b16100b1c8b8888f9ac367d4f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 11:22:08 -0700 Subject: [PATCH 489/581] Revert examples --- Examples/Examples.xcodeproj/project.pbxproj | 603 +++--------------- .../xcschemes/CaseStudies.xcscheme | 2 +- .../xcshareddata/xcschemes/Reminders.xcscheme | 13 +- .../xcshareddata/xcschemes/SyncUps.xcscheme | 2 +- Examples/Reminders/ReminderForm.swift | 6 +- Examples/Reminders/ReminderRow.swift | 91 +-- Examples/Reminders/RemindersApp.swift | 42 +- Examples/Reminders/RemindersDetail.swift | 156 ++--- Examples/Reminders/RemindersListForm.swift | 107 +--- Examples/Reminders/RemindersListRow.swift | 35 +- Examples/Reminders/RemindersLists.swift | 92 ++- Examples/Reminders/Schema.swift | 145 +++-- Examples/Reminders/SearchReminders.swift | 227 +++++-- Examples/Reminders/TagsForm.swift | 67 -- Examples/RemindersTests/Internal.swift | 6 +- .../RemindersDetailsTests.swift | 22 +- .../RemindersTests/RemindersListsTests.swift | 3 +- .../RemindersTests/SearchRemindersTests.swift | 247 +++---- Examples/SyncUps/Schema.swift | 1 + 19 files changed, 627 insertions(+), 1240 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index bc3e7f5b..99f41534 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -7,34 +7,23 @@ objects = { /* Begin PBXBuildFile section */ - CA1146CA2DF38D1D0054BA77 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA1146C92DF38D1D0054BA77 /* SharingGRDB */; }; CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; - CA42392E2DF7219E000AF560 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA42392D2DF7219E000AF560 /* SwiftUINavigation */; }; - CA9102EB2E1F299900F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EA2E1F299900F85DD0 /* SharingGRDB */; }; - CA9102ED2E1F29A400F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EC2E1F29A400F85DD0 /* SharingGRDB */; }; - CA9102EF2E1F29AA00F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */; }; - CA9102F12E1F29E300F85DD0 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CA9102F02E1F29E300F85DD0 /* SharingGRDB */; }; - CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99D72DF915D300934431 /* DependenciesTestSupport */; }; - CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */; }; - CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */; }; + CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */; }; + CA5E47072DECEF0F0069E0F8 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */; }; + CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */; }; + CA5E470B2DECF0280069E0F8 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; - DC7082542E035FC500A66B7D /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DC7082532E035FC500A66B7D /* SwiftUINavigation */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8A2E02176700FB20F8 /* SharingGRDB */; }; + DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8C2E02177200FB20F8 /* SharingGRDB */; }; + DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8E2E02177900FB20F8 /* SharingGRDB */; }; DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCF267382D48437300B680BE /* SwiftUINavigation */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - CA1146AD2DF38D000054BA77 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = CAF836902D4735620047AEB5 /* Project object */; - proxyType = 1; - remoteGlobalIDString = CA11469E2DF38CFE0054BA77; - remoteInfo = CloudKitDemo; - }; - CA9F994E2DF9134D00934431 /* PBXContainerItemProxy */ = { + CA5E469A2DEBFE410069E0F8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CAF836902D4735620047AEB5 /* Project object */; proxyType = 1; @@ -58,12 +47,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; - CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudKitDemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RemindersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CA5F37542D5AFBBC002E1A9E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; - CA9101C82E1F270100F85DD0 /* CloudKitPlayground.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitPlayground.app; sourceTree = BUILT_PRODUCTS_DIR; }; - CA9102E62E1F28FE00F85DD0 /* sharing-grdb */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "sharing-grdb"; path = "/Users/brandon/projects/sharing-grdb"; sourceTree = ""; }; - CA9F99482DF9134D00934431 /* RemindersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RemindersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836982D4735620047AEB5 /* CaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836A82D4735640047AEB5 /* CaseStudiesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CaseStudiesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -72,20 +57,6 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - CA1146CE2DF397DB0054BA77 /* Exceptions for "CloudKitDemo" folder in "CloudKitDemo" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = CA11469E2DF38CFE0054BA77 /* CloudKitDemo */; - }; - CA9101D52E1F272700F85DD0 /* Exceptions for "CloudKitPlayground" folder in "CloudKitPlayground" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = CA9101C72E1F270100F85DD0 /* CloudKitPlayground */; - }; CAD4819A2D584B510004799A /* Exceptions for "CaseStudies" folder in "CaseStudies" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -112,28 +83,7 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - CA1146A02DF38CFE0054BA77 /* CloudKitDemo */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - CA1146CE2DF397DB0054BA77 /* Exceptions for "CloudKitDemo" folder in "CloudKitDemo" target */, - ); - path = CloudKitDemo; - sourceTree = ""; - }; - CA1146AF2DF38D000054BA77 /* CloudKitDemoTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = CloudKitDemoTests; - sourceTree = ""; - }; - CA9101C92E1F270100F85DD0 /* CloudKitPlayground */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - CA9101D52E1F272700F85DD0 /* Exceptions for "CloudKitPlayground" folder in "CloudKitPlayground" target */, - ); - path = CloudKitPlayground; - sourceTree = ""; - }; - CA9F99492DF9134D00934431 /* RemindersTests */ = { + CA5E46972DEBFE410069E0F8 /* RemindersTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = RemindersTests; sourceTree = ""; @@ -175,37 +125,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - CA11469C2DF38CFE0054BA77 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - CA42392E2DF7219E000AF560 /* SwiftUINavigation in Frameworks */, - CA1146CA2DF38D1D0054BA77 /* SharingGRDB in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - CA1146A92DF38D000054BA77 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - CA9101C52E1F270100F85DD0 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - CA9102F12E1F29E300F85DD0 /* SharingGRDB in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - CA9F99452DF9134D00934431 /* Frameworks */ = { + CA5E46932DEBFE410069E0F8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CA9F99DD2DF9185A00934431 /* SnapshotTestingCustomDump in Frameworks */, - CA9F99D82DF915D300934431 /* DependenciesTestSupport in Frameworks */, - CA9F99DF2DF9190C00934431 /* InlineSnapshotTesting in Frameworks */, + CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */, + CA5E47072DECEF0F0069E0F8 /* InlineSnapshotTesting in Frameworks */, + CA5E470B2DECF0280069E0F8 /* DependenciesTestSupport in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -222,7 +148,6 @@ buildActionMask = 2147483647; files = ( DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */, - CA9102EB2E1F299900F85DD0 /* SharingGRDB in Frameworks */, CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -239,8 +164,8 @@ buildActionMask = 2147483647; files = ( CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, - CA9102ED2E1F29A400F85DD0 /* SharingGRDB in Frameworks */, - DC7082542E035FC500A66B7D /* SwiftUINavigation in Frameworks */, + DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */, + CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -250,7 +175,7 @@ files = ( DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */, DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */, - CA9102EF2E1F29AA00F85DD0 /* SharingGRDB in Frameworks */, + DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */, DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -265,12 +190,9 @@ CAF8369A2D4735620047AEB5 /* CaseStudies */, CAF836AB2D4735640047AEB5 /* CaseStudiesTests */, CAF836D92D4735AB0047AEB5 /* Reminders */, - CA9F99492DF9134D00934431 /* RemindersTests */, + CA5E46972DEBFE410069E0F8 /* RemindersTests */, DCBE89CD2D483FB90071F499 /* SyncUps */, CAD0017E2D874E6F00FA977A /* SyncUpTests */, - CA1146A02DF38CFE0054BA77 /* CloudKitDemo */, - CA1146AF2DF38D000054BA77 /* CloudKitDemoTests */, - CA9101C92E1F270100F85DD0 /* CloudKitPlayground */, CAF837022D4735C00047AEB5 /* Frameworks */, CAF836992D4735620047AEB5 /* Products */, ); @@ -284,10 +206,7 @@ CAF836D82D4735AB0047AEB5 /* Reminders.app */, DCBE89CC2D483FB90071F499 /* SyncUps.app */, CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */, - CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */, - CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */, - CA9F99482DF9134D00934431 /* RemindersTests.xctest */, - CA9101C82E1F270100F85DD0 /* CloudKitPlayground.app */, + CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */, ); name = Products; sourceTree = ""; @@ -295,7 +214,6 @@ CAF837022D4735C00047AEB5 /* Frameworks */ = { isa = PBXGroup; children = ( - CA9102E62E1F28FE00F85DD0 /* sharing-grdb */, ); name = Frameworks; sourceTree = ""; @@ -303,101 +221,31 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - CA11469E2DF38CFE0054BA77 /* CloudKitDemo */ = { - isa = PBXNativeTarget; - buildConfigurationList = CA1146C42DF38D000054BA77 /* Build configuration list for PBXNativeTarget "CloudKitDemo" */; - buildPhases = ( - CA11469B2DF38CFE0054BA77 /* Sources */, - CA11469C2DF38CFE0054BA77 /* Frameworks */, - CA11469D2DF38CFE0054BA77 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - CA1146A02DF38CFE0054BA77 /* CloudKitDemo */, - ); - name = CloudKitDemo; - packageProductDependencies = ( - CA1146C92DF38D1D0054BA77 /* SharingGRDB */, - CA42392D2DF7219E000AF560 /* SwiftUINavigation */, - ); - productName = CloudKitDemo; - productReference = CA11469F2DF38CFE0054BA77 /* CloudKitDemo.app */; - productType = "com.apple.product-type.application"; - }; - CA1146AB2DF38D000054BA77 /* CloudKitDemoTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = CA1146C52DF38D000054BA77 /* Build configuration list for PBXNativeTarget "CloudKitDemoTests" */; - buildPhases = ( - CA1146A82DF38D000054BA77 /* Sources */, - CA1146A92DF38D000054BA77 /* Frameworks */, - CA1146AA2DF38D000054BA77 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - CA1146AE2DF38D000054BA77 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - CA1146AF2DF38D000054BA77 /* CloudKitDemoTests */, - ); - name = CloudKitDemoTests; - packageProductDependencies = ( - ); - productName = CloudKitDemoTests; - productReference = CA1146AC2DF38D000054BA77 /* CloudKitDemoTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - CA9101C72E1F270100F85DD0 /* CloudKitPlayground */ = { + CA5E46952DEBFE410069E0F8 /* RemindersTests */ = { isa = PBXNativeTarget; - buildConfigurationList = CA9101D22E1F270200F85DD0 /* Build configuration list for PBXNativeTarget "CloudKitPlayground" */; + buildConfigurationList = CA5E469C2DEBFE420069E0F8 /* Build configuration list for PBXNativeTarget "RemindersTests" */; buildPhases = ( - CA9101C42E1F270100F85DD0 /* Sources */, - CA9101C52E1F270100F85DD0 /* Frameworks */, - CA9101C62E1F270100F85DD0 /* Resources */, + CA5E46922DEBFE410069E0F8 /* Sources */, + CA5E46932DEBFE410069E0F8 /* Frameworks */, + CA5E46942DEBFE410069E0F8 /* Resources */, ); buildRules = ( ); dependencies = ( + CA5E469B2DEBFE410069E0F8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - CA9101C92E1F270100F85DD0 /* CloudKitPlayground */, - ); - name = CloudKitPlayground; - packageProductDependencies = ( - CA9102F02E1F29E300F85DD0 /* SharingGRDB */, - ); - productName = CloudKitPlayground; - productReference = CA9101C82E1F270100F85DD0 /* CloudKitPlayground.app */; - productType = "com.apple.product-type.application"; - }; - CA9F99472DF9134D00934431 /* RemindersTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = CA9F99502DF9134D00934431 /* Build configuration list for PBXNativeTarget "RemindersTests" */; - buildPhases = ( - CA9F99442DF9134D00934431 /* Sources */, - CA9F99452DF9134D00934431 /* Frameworks */, - CA9F99462DF9134D00934431 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - CA9F994F2DF9134D00934431 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - CA9F99492DF9134D00934431 /* RemindersTests */, + CA5E46972DEBFE410069E0F8 /* RemindersTests */, ); name = RemindersTests; packageProductDependencies = ( - CA9F99D72DF915D300934431 /* DependenciesTestSupport */, - CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */, - CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */, + CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */, + CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */, + CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */, ); productName = RemindersTests; - productReference = CA9F99482DF9134D00934431 /* RemindersTests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; + productReference = CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; CAD0017C2D874E6F00FA977A /* SyncUpTests */ = { isa = PBXNativeTarget; @@ -442,7 +290,6 @@ packageProductDependencies = ( CA2908C82D4AF70E003F165F /* UIKitNavigation */, DCD9AC8A2E02176700FB20F8 /* SharingGRDB */, - CA9102EA2E1F299900F85DD0 /* SharingGRDB */, ); productName = Examples; productReference = CAF836982D4735620047AEB5 /* CaseStudies.app */; @@ -489,8 +336,8 @@ name = Reminders; packageProductDependencies = ( CA14DBC82DA884C400E36852 /* CasePaths */, - DC7082532E035FC500A66B7D /* SwiftUINavigation */, - CA9102EC2E1F29A400F85DD0 /* SharingGRDB */, + CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */, + DCD9AC8C2E02177200FB20F8 /* SharingGRDB */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -516,7 +363,7 @@ DCBE8A132D4842BF0071F499 /* CasePaths */, DCF267382D48437300B680BE /* SwiftUINavigation */, DC5FA7472D4C63D60082743E /* DependenciesMacros */, - CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */, + DCD9AC8E2E02177900FB20F8 /* SharingGRDB */, ); productName = SyncUps; productReference = DCBE89CC2D483FB90071F499 /* SyncUps.app */; @@ -530,19 +377,9 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1640; - LastUpgradeCheck = 1640; + LastUpgradeCheck = 1620; TargetAttributes = { - CA11469E2DF38CFE0054BA77 = { - CreatedOnToolsVersion = 16.4; - }; - CA1146AB2DF38D000054BA77 = { - CreatedOnToolsVersion = 16.4; - TestTargetID = CA11469E2DF38CFE0054BA77; - }; - CA9101C72E1F270100F85DD0 = { - CreatedOnToolsVersion = 16.4; - }; - CA9F99472DF9134D00934431 = { + CA5E46952DEBFE410069E0F8 = { CreatedOnToolsVersion = 16.4; TestTargetID = CAF836D72D4735AB0047AEB5; }; @@ -578,7 +415,8 @@ DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */, DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, - CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */, + CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, + DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -588,39 +426,15 @@ CAF836972D4735620047AEB5 /* CaseStudies */, CAF836A72D4735640047AEB5 /* CaseStudiesTests */, CAF836D72D4735AB0047AEB5 /* Reminders */, - CA9F99472DF9134D00934431 /* RemindersTests */, + CA5E46952DEBFE410069E0F8 /* RemindersTests */, DCBE89CB2D483FB90071F499 /* SyncUps */, CAD0017C2D874E6F00FA977A /* SyncUpTests */, - CA11469E2DF38CFE0054BA77 /* CloudKitDemo */, - CA1146AB2DF38D000054BA77 /* CloudKitDemoTests */, - CA9101C72E1F270100F85DD0 /* CloudKitPlayground */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - CA11469D2DF38CFE0054BA77 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - CA1146AA2DF38D000054BA77 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - CA9101C62E1F270100F85DD0 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - CA9F99462DF9134D00934431 /* Resources */ = { + CA5E46942DEBFE410069E0F8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -665,28 +479,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - CA11469B2DF38CFE0054BA77 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - CA1146A82DF38D000054BA77 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - CA9101C42E1F270100F85DD0 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - CA9F99442DF9134D00934431 /* Sources */ = { + CA5E46922DEBFE410069E0F8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -731,15 +524,10 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - CA1146AE2DF38D000054BA77 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = CA11469E2DF38CFE0054BA77 /* CloudKitDemo */; - targetProxy = CA1146AD2DF38D000054BA77 /* PBXContainerItemProxy */; - }; - CA9F994F2DF9134D00934431 /* PBXTargetDependency */ = { + CA5E469B2DEBFE410069E0F8 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = CAF836D72D4735AB0047AEB5 /* Reminders */; - targetProxy = CA9F994E2DF9134D00934431 /* PBXContainerItemProxy */; + targetProxy = CA5E469A2DEBFE410069E0F8 /* PBXContainerItemProxy */; }; CAD001822D874E6F00FA977A /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -754,227 +542,37 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - CA1146BE2DF38D000054BA77 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = CloudKitDemo/CloudKitDemo.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = VFRXY8HC3H; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = CloudKitDemo/Info.plist; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 15.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemo; - PRODUCT_NAME = "$(TARGET_NAME)"; - REGISTER_APP_GROUPS = YES; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 2.5; - }; - name = Debug; - }; - CA1146BF2DF38D000054BA77 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = CloudKitDemo/CloudKitDemo.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = VFRXY8HC3H; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = CloudKitDemo/Info.plist; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 15.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemo; - PRODUCT_NAME = "$(TARGET_NAME)"; - REGISTER_APP_GROUPS = YES; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 2.5; - }; - name = Release; - }; - CA1146C02DF38D000054BA77 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = VFRXY8HC3H; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemoTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CloudKitDemo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CloudKitDemo"; - XROS_DEPLOYMENT_TARGET = 2.5; - }; - name = Debug; - }; - CA1146C12DF38D000054BA77 /* Release */ = { + CA5E469D2DEBFE420069E0F8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = VFRXY8HC3H; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitDemoTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CloudKitDemo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CloudKitDemo"; - XROS_DEPLOYMENT_TARGET = 2.5; - }; - name = Release; - }; - CA9101D02E1F270200F85DD0 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = CloudKitPlayground/CloudKitPlayground.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = VFRXY8HC3H; - ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = CloudKitPlayground/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitPlayground; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - CA9101D12E1F270200F85DD0 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = CloudKitPlayground/CloudKitPlayground.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = VFRXY8HC3H; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = CloudKitPlayground/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CloudKitPlayground; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - CA9F99512DF9134D00934431 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = VFRXY8HC3H; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.RemindersTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Reminders; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Reminders.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Reminders"; }; name = Debug; }; - CA9F99522DF9134D00934431 /* Release */ = { + CA5E469E2DEBFE420069E0F8 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = VFRXY8HC3H; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.RemindersTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Reminders; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Reminders.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Reminders"; }; name = Release; }; @@ -1072,8 +670,6 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; }; name = Debug; @@ -1130,8 +726,6 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; @@ -1230,14 +824,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Reminders/Reminders.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Reminders/Preview Content\""; - DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -1248,7 +839,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "co.pointfree.sharing-grdb.Reminders"; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Reminders; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1260,14 +851,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Reminders/Reminders.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Reminders/Preview Content\""; - DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -1278,7 +866,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "co.pointfree.sharing-grdb.Reminders"; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Reminders; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1344,38 +932,11 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - CA1146C42DF38D000054BA77 /* Build configuration list for PBXNativeTarget "CloudKitDemo" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - CA1146BE2DF38D000054BA77 /* Debug */, - CA1146BF2DF38D000054BA77 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - CA1146C52DF38D000054BA77 /* Build configuration list for PBXNativeTarget "CloudKitDemoTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - CA1146C02DF38D000054BA77 /* Debug */, - CA1146C12DF38D000054BA77 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - CA9101D22E1F270200F85DD0 /* Build configuration list for PBXNativeTarget "CloudKitPlayground" */ = { + CA5E469C2DEBFE420069E0F8 /* Build configuration list for PBXNativeTarget "RemindersTests" */ = { isa = XCConfigurationList; buildConfigurations = ( - CA9101D02E1F270200F85DD0 /* Debug */, - CA9101D12E1F270200F85DD0 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - CA9F99502DF9134D00934431 /* Build configuration list for PBXNativeTarget "RemindersTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - CA9F99512DF9134D00934431 /* Debug */, - CA9F99522DF9134D00934431 /* Release */, + CA5E469D2DEBFE420069E0F8 /* Debug */, + CA5E469E2DEBFE420069E0F8 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -1437,14 +998,14 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */ = { + DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */ = { isa = XCLocalSwiftPackageReference; - relativePath = ..; + relativePath = "../../sharing-grdb"; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ - CA9F99D92DF9185A00934431 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { + CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing.git"; requirement = { @@ -1479,10 +1040,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - CA1146C92DF38D1D0054BA77 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - productName = SharingGRDB; - }; CA14DBC82DA884C400E36852 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; @@ -1493,45 +1050,26 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = UIKitNavigation; }; - CA42392D2DF7219E000AF560 /* SwiftUINavigation */ = { + CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = SwiftUINavigation; }; - CA9102EA2E1F299900F85DD0 /* SharingGRDB */ = { + CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */ = { isa = XCSwiftPackageProductDependency; - productName = SharingGRDB; - }; - CA9102EC2E1F29A400F85DD0 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */; - productName = SharingGRDB; - }; - CA9102EE2E1F29AA00F85DD0 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */; - productName = SharingGRDB; + package = CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = InlineSnapshotTesting; }; - CA9102F02E1F29E300F85DD0 /* SharingGRDB */ = { + CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */ = { isa = XCSwiftPackageProductDependency; - package = CA9102E92E1F299900F85DD0 /* XCLocalSwiftPackageReference ".." */; - productName = SharingGRDB; + package = CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = SnapshotTestingCustomDump; }; - CA9F99D72DF915D300934431 /* DependenciesTestSupport */ = { + CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesTestSupport; }; - CA9F99DC2DF9185A00934431 /* SnapshotTestingCustomDump */ = { - isa = XCSwiftPackageProductDependency; - package = CA9F99D92DF9185A00934431 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; - productName = SnapshotTestingCustomDump; - }; - CA9F99DE2DF9190C00934431 /* InlineSnapshotTesting */ = { - isa = XCSwiftPackageProductDependency; - package = CA9F99D92DF9185A00934431 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; - productName = InlineSnapshotTesting; - }; CAD001862D874F1F00FA977A /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; @@ -1542,11 +1080,6 @@ package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesMacros; }; - DC7082532E035FC500A66B7D /* SwiftUINavigation */ = { - isa = XCSwiftPackageProductDependency; - package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; - productName = SwiftUINavigation; - }; DCBE8A132D4842BF0071F499 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; @@ -1556,6 +1089,16 @@ isa = XCSwiftPackageProductDependency; productName = SharingGRDB; }; + DCD9AC8C2E02177200FB20F8 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; + productName = SharingGRDB; + }; + DCD9AC8E2E02177900FB20F8 /* SharingGRDB */ = { + isa = XCSwiftPackageProductDependency; + package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; + productName = SharingGRDB; + }; DCF267382D48437300B680BE /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; diff --git a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CaseStudies.xcscheme b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CaseStudies.xcscheme index 166dbd72..5c30788d 100644 --- a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CaseStudies.xcscheme +++ b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CaseStudies.xcscheme @@ -1,6 +1,6 @@ - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> diff --git a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme index edb94fdb..97c6d079 100644 --- a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme +++ b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme @@ -1,6 +1,6 @@ some View { - return HStack(alignment: .firstTextBaseline) { + @ViewBuilder + private func title(for reminder: Reminder, title: String?) -> some View { + HStack(alignment: .firstTextBaseline) { if let priority = reminder.priority { Text(String(repeating: "!", count: priority.rawValue)) - .foregroundStyle(reminder.isCompleted ? .gray : remindersList.color) + .foregroundStyle(isCompleted ? .gray : remindersList.color) } - Text(reminder.title) - .foregroundStyle(reminder.isCompleted ? .gray : .primary) + highlight(title ?? reminder.title) + .foregroundStyle(isCompleted ? .gray : .primary) } .font(.title3) } + + func highlight(_ text: String) -> Text { + if let attributedText = try? AttributedString(markdown: text) { + Text(attributedText) + } else { + Text(text) + } + } } struct ReminderRowPreview: PreviewProvider { @@ -190,7 +199,7 @@ struct ReminderRowPreview: PreviewProvider { reminder: reminder, remindersList: remindersList, showCompleted: true, - tags: ["point-free", "adulting"] + tags: "#point-free #adulting" ) } } diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index b1f3690f..d5c55805 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,20 +1,15 @@ -import CloudKit -import Combine -import Dependencies import SharingGRDB import SwiftUI -import UIKit @main struct RemindersApp: App { - @UIApplicationDelegateAdaptor var delegate: AppDelegate @Dependency(\.context) var context static let model = RemindersListsModel() init() { if context == .live { try! prepareDependencies { - try $0.bootstrapDatabase() + $0.defaultDatabase = try Reminders.appDatabase() } } } @@ -29,38 +24,3 @@ struct RemindersApp: App { } } } - -class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return true - } - - func application( - _ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions - ) -> UISceneConfiguration { - let configuration = UISceneConfiguration( - name: "Default Configuration", - sessionRole: connectingSceneSession.role - ) - configuration.delegateClass = SceneDelegate.self - return configuration - } -} - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - func windowScene( - _ windowScene: UIWindowScene, - userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata - ) { - @Dependency(\.defaultSyncEngine) var syncEngine - Task { - try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) - } - } -} diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 42a818a6..1f1ea51f 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,5 +1,4 @@ import CasePaths -import CloudKit import SharingGRDB import SwiftUI import SwiftUINavigation @@ -8,16 +7,13 @@ import SwiftUINavigation @Observable class RemindersDetailModel: HashableObject { @ObservationIgnored @FetchAll var reminderRows: [Row] - @ObservationIgnored @FetchOne var coverImageData: Data? @ObservationIgnored @Shared var ordering: Ordering @ObservationIgnored @Shared var showCompleted: Bool let detailType: DetailType var isNewReminderSheetPresented = false - var sharedRecord: SharedRecord? @ObservationIgnored @Dependency(\.defaultDatabase) private var database - @ObservationIgnored @Dependency(\.defaultSyncEngine) private var syncEngine init(detailType: DetailType) { self.detailType = detailType @@ -26,15 +22,7 @@ class RemindersDetailModel: HashableObject { wrappedValue: detailType == .completed, .appStorage("show_completed_list_\(detailType.id)") ) - _reminderRows = FetchAll(remindersQuery, animation: .default) - if let remindersListID = detailType.remindersList?.id { - _coverImageData = FetchOne( - RemindersListAsset - .where { $0.remindersListID.eq(remindersListID) } - .select(\.coverImage), - animation: .default - ) - } + _reminderRows = FetchAll(remindersQuery) } func orderingButtonTapped(_ ordering: Ordering) async { @@ -52,11 +40,19 @@ class RemindersDetailModel: HashableObject { try database.write { db in var ids = reminderRows.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) - for (offset, id) in ids.enumerated() { - try Reminder.find(id) - .update { $0.position = offset } - .execute(db) - } + try Reminder + .where { $0.id.in(ids) } + .update { + let ids = Array(ids.enumerated()) + let (first, rest) = (ids.first!, ids.dropFirst()) + $0.position = + rest + .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in + cases.when(id.element, then: id.offset) + } + .else($0.position) + } + .execute(db) } } $ordering.withLock { $0 = .manual } @@ -69,16 +65,6 @@ class RemindersDetailModel: HashableObject { } } - func shareButtonTapped() async { - guard let remindersList = detailType.remindersList - else { return } - sharedRecord = await withErrorReporting { - try await syncEngine.share(record: remindersList) { share in - share[CKShare.SystemFieldKey.title] = remindersList.title - } - } - } - private var remindersQuery: some StructuredQueriesCore.Statement { Reminder .where { @@ -86,7 +72,7 @@ class RemindersDetailModel: HashableObject { !$0.isCompleted } } - .order { $0.isCompleted } + .order(by: \.isCompleted) .order { switch ordering { case .dueDate: $0.dueDate.asc(nulls: .last) @@ -108,13 +94,14 @@ class RemindersDetailModel: HashableObject { } } .join(RemindersList.all) { $0.remindersListID.eq($3.id) } + .join(ReminderText.all) { $0.rowid.eq($4.rowid) } .select { Row.Columns( reminder: $0, remindersList: $3, isPastDue: $0.isPastDue, - notes: $0.inlineNotes.substr(0, 200), - tags: #sql("\($2.jsonTitles)") + notes: $4.notes.substr(0, 200), + tags: $4.tags ) } } @@ -153,8 +140,7 @@ class RemindersDetailModel: HashableObject { let remindersList: RemindersList let isPastDue: Bool let notes: String - @Column(as: [String].JSONRepresentation.self) - let tags: [String] + let tags: String } } @@ -166,8 +152,15 @@ struct RemindersDetailView: View { var body: some View { List { - header - + VStack(alignment: .leading) { + GeometryReader { proxy in + Text(model.detailType.navigationTitle) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(model.detailType.color) + .onAppear { navigationTitleHeight = proxy.size.height } + } + } + .listRowSeparator(.hidden) ForEach(model.reminderRows) { row in ReminderRow( color: model.detailType.color, @@ -196,13 +189,10 @@ struct RemindersDetailView: View { reminder: Reminder.Draft(remindersListID: remindersList.id), remindersList: remindersList ) - .navigationTitle("New Reminder") + .navigationTitle("New Reminder") } } } - .sheet(item: $model.sharedRecord) { sharedRecord in - CloudSharingView(sharedRecord: sharedRecord) - } .toolbar { ToolbarItem(placement: .principal) { Text(model.detailType.navigationTitle) @@ -229,81 +219,37 @@ struct RemindersDetailView: View { } } ToolbarItem(placement: .primaryAction) { - HStack(alignment: .firstTextBaseline) { - if model.detailType.is(\.remindersList) { - Button { - Task { await model.shareButtonTapped() } - } label: { - Image(systemName: "square.and.arrow.up") - } - } - Menu { - Group { - Menu { - ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in - Button { - Task { await model.orderingButtonTapped(ordering) } - } label: { - Text(ordering.rawValue) - ordering.icon - } + Menu { + Group { + Menu { + ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in + Button { + Task { await model.orderingButtonTapped(ordering) } + } label: { + Text(ordering.rawValue) + ordering.icon } - } label: { - Text("Sort By") - Text(model.ordering.rawValue) - Image(systemName: "arrow.up.arrow.down") - } - Button { - Task { await model.showCompletedButtonTapped() } - } label: { - Text(model.showCompleted ? "Hide Completed" : "Show Completed") - Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") } + } label: { + Text("Sort By") + Text(model.ordering.rawValue) + Image(systemName: "arrow.up.arrow.down") + } + Button { + Task { await model.showCompletedButtonTapped() } + } label: { + Text(model.showCompleted ? "Hide Completed" : "Show Completed") + Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") } - .tint(model.detailType.color) - } label: { - Image(systemName: "ellipsis.circle") } + .tint(model.detailType.color) + } label: { + Image(systemName: "ellipsis.circle") } } } .toolbarTitleDisplayMode(.inline) } - - @ViewBuilder - var header: some View { - if let coverImageData = model.coverImageData, let image = UIImage(data: coverImageData) { - ZStack { - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(maxHeight: 200) - .clipped() - - GeometryReader { proxy in - Text(model.detailType.navigationTitle) - .font(.system(.largeTitle, design: .rounded, weight: .bold)) - .foregroundStyle(model.detailType.color) - .padding() - .background(Color.black.opacity(0.6)) - .cornerRadius(10) - .padding() - .onAppear { navigationTitleHeight = proxy.size.height } - } - } - .listRowInsets(EdgeInsets()) - } else { - VStack(alignment: .leading) { - GeometryReader { proxy in - Text(model.detailType.navigationTitle) - .font(.system(.largeTitle, design: .rounded, weight: .bold)) - .foregroundStyle(model.detailType.color) - .onAppear { navigationTitleHeight = proxy.size.height } - } - } - .listRowSeparator(.hidden) - } - } } extension RemindersDetailModel.DetailType { diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 875f3013..7c9126d2 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -1,5 +1,4 @@ import IssueReporting -import PhotosUI import SharingGRDB import SwiftUI @@ -7,9 +6,6 @@ struct RemindersListForm: View { @Dependency(\.defaultDatabase) private var database @State var remindersList: RemindersList.Draft - @State var coverImageData: Data? - @State var photosPickerItem: PhotosPickerItem? - @State private var isPhotoPickerPresented = false @Environment(\.dismiss) var dismiss init(remindersList: RemindersList.Draft) { @@ -31,69 +27,15 @@ struct RemindersListForm: View { .clipShape(.buttonBorder) } ColorPicker("Color", selection: $remindersList.color) - ZStack(alignment: .topTrailing) { - ZStack { - if let coverImageData, - let uiImage = UIImage(data: coverImageData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - .frame(height: 150) - .clipped() - .cornerRadius(10) - } else { - Rectangle() - .fill(Color.secondary.opacity(0.1)) - .frame(height: 150) - .cornerRadius(10) - } - - Button("Select Cover Image") { - isPhotoPickerPresented = true - } - .padding() - .background(.ultraThinMaterial) - .clipShape(.capsule) - } - - if coverImageData != nil { - Button { - coverImageData = nil - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) - .background(Color.white) - .clipShape(Circle()) - } - .padding(8) - } - } - .buttonStyle(.plain) } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem { Button("Save") { - Task { [remindersList, coverImageData] in - await withErrorReporting { - try await database.write { db in - let remindersListID = try RemindersList - .upsert { remindersList } - .returning(\.id) - .fetchOne(db) - guard let remindersListID - else { - reportIssue("No 'remindersListID'") - return - } - try RemindersListAsset.upsert { - RemindersListAsset.Draft( - remindersListID: remindersListID, - coverImage: coverImageData - ) - } + withErrorReporting { + try database.write { db in + try RemindersList.upsert { remindersList } .execute(db) - } } } dismiss() @@ -105,52 +47,9 @@ struct RemindersListForm: View { } } } - .photosPicker(isPresented: $isPhotoPickerPresented, selection: $photosPickerItem) - .onChange(of: photosPickerItem) { - Task { - await withErrorReporting { - if let photosPickerItem { - coverImageData = try await photosPickerItem.loadTransferable(type: Data.self) - .flatMap { resizedAndOptimizedImageData(from: $0) } - self.photosPickerItem = nil - } - } - } - } - .task { - guard let remindersListID = remindersList.id - else { return } - do { - coverImageData = try await database.read { db in - try RemindersListAsset - .where { $0.remindersListID.eq(remindersListID) } - .select(\.coverImage) - .fetchOne(db) ?? nil - } - } catch is CancellationError { - } catch { - reportIssue(error) - } - } } } -func resizedAndOptimizedImageData(from data: Data, maxWidth: CGFloat = 1000) -> Data? { - guard let image = UIImage(data: data) else { return nil } - - let originalSize = image.size - let scaleFactor = min(1, maxWidth / originalSize.width) - let newSize = CGSize(width: originalSize.width * scaleFactor, - height: originalSize.height * scaleFactor) - - UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) - image.draw(in: CGRect(origin: .zero, size: newSize)) - let resizedImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return resizedImage?.jpegData(compressionQuality: 0.8) -} - struct RemindersListFormPreviews: PreviewProvider { static var previews: some View { let _ = try! prepareDependencies { diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 33f387e9..9efeeff2 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -1,16 +1,13 @@ -import CloudKit import SharingGRDB import SwiftUI struct RemindersListRow: View { let remindersCount: Int let remindersList: RemindersList - let share: CKShare? @State var editList: RemindersList? @Dependency(\.defaultDatabase) private var database - @Dependency(\.defaultSyncEngine) private var syncEngine var body: some View { HStack { @@ -20,14 +17,7 @@ struct RemindersListRow: View { .background( Color.white.clipShape(Circle()).padding(4) ) - VStack(alignment: .leading, spacing: 4) { - Text(remindersList.title) - if let shareMessage { - Text(shareMessage) - .font(.footnote) - .foregroundStyle(Color.secondary) - } - } + Text(remindersList.title) Spacer() Text("\(remindersCount)") .foregroundStyle(.gray) @@ -58,26 +48,6 @@ struct RemindersListRow: View { .presentationDetents([.medium]) } } - - var shareMessage: String? { - guard let share - else { return nil } - if share.owner == share.currentUserParticipant { - let participantNames = share.participants - .filter { $0 != share.currentUserParticipant } - .compactMap { $0.userIdentity.nameComponents?.formatted() } - .joined(separator: ", ") - if participantNames.count > 0 { - return "Shared with \(participantNames)" - } else { - return "Shared" - } - } else if let ownerName = share.owner.userIdentity.nameComponents?.formatted() { - return "Shared from \(ownerName)" - } else { - return nil - } - } } #Preview { @@ -88,8 +58,7 @@ struct RemindersListRow: View { remindersList: RemindersList( id: UUID(), title: "Personal" - ), - share: nil + ) ) } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 02d88a3c..e6daf88c 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -1,4 +1,3 @@ -import CloudKit import SharingGRDB import SwiftUI import SwiftUINavigation @@ -13,13 +12,8 @@ class RemindersListsModel { .group(by: \.id) .order(by: \.position) .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted } - .leftJoin(SyncMetadata.all) { $0.recordName.eq($2.recordName) } - .select { remindersList, reminder, metadata in - ReminderListState.Columns( - remindersCount: reminder.id.count(), - remindersList: remindersList, - share: metadata.share - ) + .select { + ReminderListState.Columns(remindersCount: $1.id.count(), remindersList: $0) }, animation: .default ) @@ -38,15 +32,14 @@ class RemindersListsModel { @ObservationIgnored @FetchOne( - Reminder - .select { - Stats.Columns( - allCount: $0.count(filter: !$0.isCompleted), - flaggedCount: $0.count(filter: $0.isFlagged && !$0.isCompleted), - scheduledCount: $0.count(filter: $0.isScheduled), - todayCount: $0.count(filter: $0.isToday) - ) - } + Reminder.select { + Stats.Columns( + allCount: $0.count(filter: !$0.isCompleted), + flaggedCount: $0.count(filter: $0.isFlagged), + scheduledCount: $0.count(filter: $0.isScheduled), + todayCount: $0.count(filter: $0.isToday) + ) + } ) var stats = Stats() @@ -113,35 +106,31 @@ class RemindersListsModel { try database.write { db in var ids = remindersLists.map(\.remindersList.id) ids.move(fromOffsets: source, toOffset: destination) - for (offset, id) in ids.enumerated() { - try RemindersList.find(id) - .update { $0.position = offset } - .execute(db) - } + try RemindersList + .where { $0.id.in(ids) } + .update { + let ids = Array(ids.enumerated()) + let (first, rest) = (ids.first!, ids.dropFirst()) + $0.position = + rest + .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in + cases.when(id.element, then: id.offset) + } + .else($0.position) + } + .execute(db) } } } - func deleteTags(indexSet: IndexSet) { + #if DEBUG + func seedDatabaseButtonTapped() { withErrorReporting { - let tagTitles = indexSet.map { tags[$0].title } try database.write { db in - try Tag - .where { $0.title.in(tagTitles) } - .delete() - .execute(db) + try db.seedSampleData() } } } - - #if DEBUG - func seedDatabaseButtonTapped() { - withErrorReporting { - try database.write { db in - try db.seedSampleData() - } - } - } #endif @CasePathable @@ -156,8 +145,6 @@ class RemindersListsModel { var id: RemindersList.ID { remindersList.id } var remindersCount: Int var remindersList: RemindersList - @Column(as: CKShare?.SystemFieldsRepresentation.self) - var share: CKShare? } @Selection @@ -186,7 +173,9 @@ struct RemindersListsView: View { var body: some View { List { - if model.searchRemindersModel.searchText.isEmpty { + if model.searchRemindersModel.isSearching { + SearchRemindersView(model: model.searchRemindersModel) + } else { Section { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) { GridRow { @@ -248,11 +237,9 @@ struct RemindersListsView: View { } label: { RemindersListRow( remindersCount: state.remindersCount, - remindersList: state.remindersList, - share: state.share + remindersList: state.remindersList ) } - .buttonStyle(.borderless) .foregroundStyle(.primary) } .onMove(perform: model.move(from:to:)) @@ -275,9 +262,6 @@ struct RemindersListsView: View { } .foregroundStyle(.primary) } - .onDelete { indexSet in - model.deleteTags(indexSet: indexSet) - } } header: { Text("Tags") .font(.system(.title2, design: .rounded, weight: .bold)) @@ -287,8 +271,6 @@ struct RemindersListsView: View { .padding([.leading, .trailing], 4) } .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) - } else { - SearchRemindersView(model: model.searchRemindersModel) } } .onAppear { @@ -297,7 +279,7 @@ struct RemindersListsView: View { .listStyle(.insetGrouped) .toolbar { #if DEBUG - ToolbarItem(placement: .automatic) { + ToolbarItem(placement: .automatic) { Menu { Button { model.seedDatabaseButtonTapped() @@ -346,7 +328,17 @@ struct RemindersListsView: View { } .presentationDetents([.medium]) } - .searchable(text: $model.searchRemindersModel.searchText) + .searchable( + text: $model.searchRemindersModel.searchText, + tokens: $model.searchRemindersModel.searchTokens + ) { token in + switch token.kind { + case .near: + Text(token.rawValue) + case .tag: + Text("#\(token.rawValue)") + } + } .navigationDestination(item: $model.destination.detail) { detailModel in RemindersDetailView(model: detailModel) } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 20a1a913..f2eed39f 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -19,14 +19,6 @@ struct RemindersList: Hashable, Identifiable { extension RemindersList.Draft: Identifiable {} -@Table -struct RemindersListAsset: Hashable, Identifiable { - @Column(primaryKey: true) - let remindersListID: RemindersList.ID - var coverImage: Data? - var id: RemindersList.ID { remindersListID } -} - @Table struct Reminder: Codable, Equatable, Identifiable { let id: UUID @@ -57,12 +49,6 @@ enum Priority: Int, Codable, QueryBindable { extension Reminder { static let incomplete = Self.where { !$0.isCompleted } - static func searching(_ text: String) -> Where { - Self.where { - $0.title.collate(.nocase).contains(text) - || $0.notes.collate(.nocase).contains(text) - } - } static let withTags = group(by: \.id) .leftJoin(ReminderTag.all) { $0.id.eq($1.reminderID) } .leftJoin(Tag.all) { $1.tagID.eq($2.primaryKey) } @@ -80,9 +66,6 @@ extension Reminder.TableColumns { var isScheduled: some QueryExpression { !isCompleted && dueDate.isNot(nil) } - var inlineNotes: some QueryExpression { - notes.replace("\n", " ") - } } extension Tag { @@ -91,12 +74,6 @@ extension Tag { .leftJoin(Reminder.all) { $1.reminderID.eq($2.id) } } -extension Tag.TableColumns { - var jsonTitles: some QueryExpression<[String].JSONRepresentation> { - self.title.jsonGroupArray(filter: self.title.isNot(nil)) - } -} - @Table("remindersTags") struct ReminderTag: Hashable, Identifiable { let id: UUID @@ -104,26 +81,20 @@ struct ReminderTag: Hashable, Identifiable { var tagID: Tag.ID } -extension DependencyValues { - mutating func bootstrapDatabase() throws { - defaultDatabase = try Reminders.appDatabase() - defaultSyncEngine = try SyncEngine( - for: defaultDatabase, - tables: RemindersList.self, - RemindersListAsset.self, - Reminder.self, - Tag.self, - ReminderTag.self - ) - } +@Table @Selection +struct ReminderText: StructuredQueries.FTS5 { + let rowid: Int + let title: String + let notes: String + let tags: String } func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() + configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in - try db.attachMetadatabase() #if DEBUG db.trace(options: .profile) { if context == .live { @@ -141,17 +112,11 @@ func appDatabase() throws -> any DatabaseWriter { context == .live ? URL.documentsDirectory.appending(component: "db.sqlite").path() : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - logger.debug( - """ - App database - open "\(path)" - """ - ) + logger.info("open \(path)") database = try DatabasePool(path: path, configuration: configuration) } var migrator = DatabaseMigrator() #if DEBUG - // TODO: should we warn against this for CK apps? migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create initial tables") { db in @@ -160,19 +125,9 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "remindersLists" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "color" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT \(raw: defaultListColor ?? 0), - "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' - ) STRICT - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "remindersListAssets" ( - "remindersListID" TEXT PRIMARY KEY NOT NULL - REFERENCES "remindersLists"("id") ON DELETE CASCADE, - "coverImage" BLOB + "color" INTEGER NOT NULL DEFAULT \(raw: defaultListColor ?? 0), + "position" INTEGER NOT NULL DEFAULT 0, + "title" TEXT NOT NULL ) STRICT """ ) @@ -182,13 +137,13 @@ func appDatabase() throws -> any DatabaseWriter { CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "dueDate" TEXT, - "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "isFlagged" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "notes" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isCompleted" INTEGER NOT NULL DEFAULT 0, + "isFlagged" INTEGER NOT NULL DEFAULT 0, + "notes" TEXT, + "position" INTEGER NOT NULL DEFAULT 0, "priority" INTEGER, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + "title" TEXT NOT NULL ) STRICT """ ) @@ -211,6 +166,17 @@ func appDatabase() throws -> any DatabaseWriter { """ ) .execute(db) + try #sql( + """ + CREATE VIRTUAL TABLE "reminderTexts" USING fts5( + "title", + "notes", + "tags", + tokenize = 'trigram' + ) + """ + ) + .execute(db) } try migrator.migrate(database) @@ -226,12 +192,14 @@ func appDatabase() throws -> any DatabaseWriter { .update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1} } }) .execute(db) + try Reminder.createTemporaryTrigger(after: .insert { new in Reminder .find(new.id) .update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1} } }) .execute(db) + try RemindersList.createTemporaryTrigger(after: .delete { _ in RemindersList.insert { RemindersList.Draft( @@ -243,6 +211,61 @@ func appDatabase() throws -> any DatabaseWriter { !RemindersList.exists() }) .execute(db) + + try Reminder.createTemporaryTrigger(after: .insert { new in + ReminderText.insert { + ReminderText.Columns( + rowid: new.rowid, + title: new.title, + notes: new.notes.replace("\n", " "), + tags: "" + ) + } + }) + .execute(db) + + try Reminder.createTemporaryTrigger(after: .update { + ($0.title, $0.notes) + } forEachRow: { _, new in + ReminderText + .where { $0.rowid.eq(new.rowid) } + .update { + $0.title = new.title + $0.notes = new.notes.replace("\n", " ") + } + }) + .execute(db) + + try Reminder.createTemporaryTrigger(after: .delete { old in + ReminderText + .where { $0.rowid.eq(old.rowid) } + .delete() + }) + .execute(db) + + func updateReminderTextTags( + for reminderID: some QueryExpression + ) -> UpdateOf { + ReminderText + .where { $0.rowid.eq(Reminder.find(reminderID).select(\.rowid)) } + .update { + $0.tags = ReminderTag + .order(by: \.tagID) + .where { $0.reminderID.eq(reminderID) } + .join(Tag.all) { $0.tagID.eq($1.primaryKey) } + .select { ("#" + $1.title).groupConcat(" ") ?? "" } + } + } + + try ReminderTag.createTemporaryTrigger(after: .insert { new in + updateReminderTextTags(for: new.reminderID) + }) + .execute(db) + + try ReminderTag.createTemporaryTrigger(after: .delete { old in + updateReminderTextTags(for: old.reminderID) + }) + .execute(db) } return database diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 1c73ba6b..472ab5ec 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -5,29 +5,67 @@ import SwiftUI @MainActor @Observable class SearchRemindersModel { - var showCompletedInSearchResults = false + var showCompletedInSearchResults = false { + didSet { + searchTask = Task { try await updateQuery(debounce: false) } + } + } + var searchText = "" { didSet { - Task { await updateQuery() } + if oldValue != searchText { + if searchText.hasSuffix("\t") { + searchTokens.append(Token(kind: .near, rawValue: String(searchText.dropLast()))) + searchText = "" + } + + searchTask = Task { try await updateQuery() } + } + } + } + + var searchTokens: [Token] = [] { + didSet { + if oldValue != searchTokens { + searchTask = Task { try await updateQuery() } + } + } + } + + var isSearching: Bool { + !searchText.isEmpty || !searchTokens.isEmpty + } + + var searchTask: Task? { + willSet { + searchTask?.cancel() } } - @ObservationIgnored @FetchOne var completedCount: Int = 0 - @ObservationIgnored @FetchAll var reminders: [Row] + @ObservationIgnored @Dependency(\.continuousClock) private var clock @ObservationIgnored @Dependency(\.defaultDatabase) private var database - func showCompletedButtonTapped() async { + @ObservationIgnored @Fetch var searchResults = SearchRequest.Value() + + @ObservationIgnored @FetchAll(Tag.none) var tags + + func showCompletedButtonTapped() async throws { showCompletedInSearchResults.toggle() - await updateQuery() + try await updateQuery() + } + + func tagButtonTapped(_ tag: Tag) { + guard !searchText.isEmpty else { return } + searchTokens.append(Token(kind: .tag, rawValue: tag.title)) + searchText = "" } func deleteCompletedReminders(monthsAgo: Int? = nil) { withErrorReporting { try database.write { db in try Reminder - .searching(searchText) - .where(\.isCompleted) + .where { $0.isCompleted && $0.id.in(baseQuery.select { $1.id }) } .where { if let monthsAgo { #sql("\($0.dueDate) < date('now', '-\(raw: monthsAgo) months')") @@ -39,51 +77,118 @@ class SearchRemindersModel { } } - private func updateQuery() async { + private var baseQuery: SelectOf { + let searchText = searchText.quoted() + + return + ReminderText + .where { + if !searchText.isEmpty { + $0.match(searchText) + } + } + .where { + for token in searchTokens { + switch token.kind { + case .near: + $0.match("NEAR(\(token.rawValue.quoted())") + case .tag: + $0.tags.match(token.rawValue) + } + } + } + .join(Reminder.all) { $0.rowid.eq($1.rowid) } + } + + private func updateQuery(debounce: Bool = true) async throws { + if debounce { + try await clock.sleep(for: .seconds(0.3)) + } await withErrorReporting { - if searchText.isEmpty { + if !isSearching { showCompletedInSearchResults = false } - try await $completedCount.load( - Reminder.searching(searchText) - .where(\.isCompleted) - .count(), - animation: .default - ) - try await $reminders.load( - Reminder - .searching(searchText) + + if searchText.hasPrefix("#") { + let existingTags = searchTokens.compactMap { $0.kind == .tag ? $0.rawValue : nil } + try await $tags.load( + Tag + .where { $0.title.hasPrefix(searchText.dropFirst()) && !$0.title.in(existingTags) } + .order(by: \.title) + ) + } else { + try await $searchResults.load( + SearchRequest( + baseQuery: baseQuery, + showCompletedInSearchResults: showCompletedInSearchResults + ), + animation: .default + ) + } + } + } + + @Selection + struct Row: Identifiable { + var id: Reminder.ID { reminder.id } + let isPastDue: Bool + let notes: String + let reminder: Reminders.Reminder + let remindersList: RemindersList + let tags: String + let title: String + } + + struct SearchRequest: FetchKeyRequest { + struct Value { + var completedCount = 0 + var rows: [Row] = [] + } + let baseQuery: SelectOf + let showCompletedInSearchResults: Bool + func fetch(_ db: Database) throws -> Value { + try Value( + completedCount: + baseQuery + .where { $1.isCompleted } + .count() + .fetchOne(db) ?? 0, + rows: + baseQuery .where { if !showCompletedInSearchResults { - !$0.isCompleted + !$1.isCompleted } } - .order { ($0.isCompleted, $0.dueDate) } - .withTags - .join(RemindersList.all) { $0.remindersListID.eq($3.id) } + .order { + ($1.isCompleted, $1.dueDate) + } + .join(RemindersList.all) { $1.remindersListID.eq($2.id) } .select { Row.Columns( - isPastDue: $0.isPastDue, - notes: $0.inlineNotes, - reminder: $0, - remindersList: $3, - tags: #sql("\($2.jsonTitles)") + isPastDue: $1.isPastDue, + notes: $0.notes.snippet("**", "**", "...", 64).replace("\n", " "), + reminder: $1, + remindersList: $2, + tags: $0.tags.highlight("**", "**"), + title: $0.title.highlight("**", "**") ) - }, - animation: .default + } + .fetchAll(db) ) } } - @Selection - struct Row: Identifiable { - var id: Reminder.ID { reminder.id } - let isPastDue: Bool - let notes: String - let reminder: Reminders.Reminder - let remindersList: RemindersList - @Column(as: [String].JSONRepresentation.self) - let tags: [String] + struct Token: Hashable, Identifiable { + enum Kind { + case near + case tag + } + + var kind: Kind + var rawValue = "" + + var id: Self { self } } } @@ -95,11 +200,26 @@ struct SearchRemindersView: View { } var body: some View { + if model.searchText.hasPrefix("#"), !model.tags.isEmpty { + Section { + ScrollView(.horizontal) { + HStack { + ForEach(model.tags) { tag in + Button("#\(tag.title)") { + model.tagButtonTapped(tag) + } + } + } + } + .scrollIndicators(.hidden) + } + } + HStack { - Text("\(model.completedCount) Completed") + Text("\(model.searchResults.completedCount) Completed") .monospacedDigit() .contentTransition(.numericText()) - if model.completedCount > 0 { + if model.searchResults.completedCount > 0 { Text("•") Menu { Text("Clear Completed Reminders") @@ -120,21 +240,22 @@ struct SearchRemindersView: View { } Spacer() Button(model.showCompletedInSearchResults ? "Hide" : "Show") { - Task { await model.showCompletedButtonTapped() } + Task { try await model.showCompletedButtonTapped() } } } } .buttonStyle(.borderless) - ForEach(model.reminders) { reminder in + ForEach(model.searchResults.rows) { row in ReminderRow( - color: reminder.remindersList.color, - isPastDue: reminder.isPastDue, - notes: reminder.notes, - reminder: reminder.reminder, - remindersList: reminder.remindersList, + color: row.remindersList.color, + isPastDue: row.isPastDue, + notes: row.notes, + reminder: row.reminder, + remindersList: row.remindersList, showCompleted: model.showCompletedInSearchResults, - tags: reminder.tags + tags: row.tags, + title: row.title ) } } @@ -157,3 +278,11 @@ struct SearchRemindersView: View { .searchable(text: $searchText) } } + +extension String { + fileprivate func quoted() -> String { + split(separator: " ") + .map { #""\#($0.replacingOccurrences(of: #"""#, with: #""""#))""# } + .joined(separator: " ") + } +} diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index b1342219..ca5c9284 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -1,25 +1,15 @@ import SharingGRDB import SwiftUI -import SwiftUINavigation struct TagsView: View { @Fetch(Tags()) var tags = Tags.Value() @Binding var selectedTags: [Tag] - @State var editingTag: Tag.Draft? - @State var tagTitle = "" - @Dependency(\.defaultDatabase) var database @Environment(\.dismiss) var dismiss var body: some View { Form { let selectedTagIDs = Set(selectedTags.map(\.id)) - Section { - Button("New tag") { - tagTitle = "" - editingTag = Tag.Draft() - } - } if !tags.top.isEmpty { Section { ForEach(tags.top, id: \.id) { tag in @@ -28,14 +18,6 @@ struct TagsView: View { selectedTags: $selectedTags, tag: tag ) - .swipeActions { - Button("Delete", role: .destructive) { - deleteButtonTapped(tag: tag) - } - Button("Edit") { - editButtonTapped(tag: tag) - } - } } } header: { Text("Top tags") @@ -49,26 +31,10 @@ struct TagsView: View { selectedTags: $selectedTags, tag: tag ) - .swipeActions { - Button("Delete", role: .destructive) { - deleteButtonTapped(tag: tag) - } - Button("Edit") { - editButtonTapped(tag: tag) - } - } } } } } - .alert(item: $editingTag) { item in - Text(item.title == nil ? "New tag" : "Edit tag") - } actions: { item in - TextField("Tag name", text: $tagTitle) - Button("Save") { - saveButtonTapped() - } - } .toolbar { ToolbarItem { Button("Done") { dismiss() } @@ -77,39 +43,6 @@ struct TagsView: View { .navigationTitle(Text("Tags")) } - func deleteButtonTapped(tag: Tag) { - withErrorReporting { - try database.write { db in - try Tag.find(tag.title).delete().execute(db) - } - } - } - - func editButtonTapped(tag: Tag) { - tagTitle = tag.title - editingTag = Tag.Draft(tag) - } - - func saveButtonTapped() { - defer { tagTitle = "" } - let tag = Tag(title: tagTitle) - withErrorReporting { - try database.write { db in - if let existingTagTitle = editingTag?.title { - selectedTags.removeAll(where: { $0.title == existingTagTitle }) - try Tag - .update { $0.title = tagTitle } - .where { $0.title.eq(existingTagTitle) } - .execute(db) - } else { - try Tag.insert(or: .ignore) { tag } - .execute(db) - } - } - selectedTags.append(tag) - } - } - struct Tags: FetchKeyRequest { func fetch(_ db: Database) throws -> Value { let top = diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index c2962e65..35986dd8 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -1,19 +1,17 @@ -import Dependencies -import DependenciesTestSupport import CustomDump import Foundation import SharingGRDB -import SnapshotTesting import SwiftUI import Testing @testable import Reminders @Suite( + .dependency(\.continuousClock, ImmediateClock()), .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)), .dependency(\.uuid, .incrementing), .dependencies { - try $0.bootstrapDatabase() + $0.defaultDatabase = try Reminders.appDatabase() try $0.defaultDatabase.write { try $0.seedSampleData() } }, .snapshots(record: .failed) diff --git a/Examples/RemindersTests/RemindersDetailsTests.swift b/Examples/RemindersTests/RemindersDetailsTests.swift index d3efc6d7..c4f9cd3d 100644 --- a/Examples/RemindersTests/RemindersDetailsTests.swift +++ b/Examples/RemindersTests/RemindersDetailsTests.swift @@ -1,8 +1,6 @@ import Dependencies import DependenciesTestSupport -import GRDB import InlineSnapshotTesting -import SharingGRDB import SnapshotTestingCustomDump import Testing @@ -40,10 +38,7 @@ extension BaseTestSuite { ), isPastDue: true, notes: "", - tags: [ - [0]: "optional", - [1]: "someday" - ] + tags: "#someday #optional" ), [1]: RemindersDetailModel.Row( reminder: Reminder( @@ -65,9 +60,7 @@ extension BaseTestSuite { ), isPastDue: false, notes: "Ask about diet", - tags: [ - [0]: "adulting" - ] + tags: "#adulting" ), [2]: RemindersDetailModel.Row( reminder: Reminder( @@ -89,10 +82,7 @@ extension BaseTestSuite { ), isPastDue: false, notes: "", - tags: [ - [0]: "night", - [1]: "social" - ] + tags: "#social #night" ), [3]: RemindersDetailModel.Row( reminder: Reminder( @@ -120,11 +110,7 @@ extension BaseTestSuite { ), isPastDue: false, notes: "Milk Eggs Apples Oatmeal Spinach", - tags: [ - [0]: "adulting", - [1]: "optional", - [2]: "someday" - ] + tags: "#someday #optional #adulting" ) ] """# diff --git a/Examples/RemindersTests/RemindersListsTests.swift b/Examples/RemindersTests/RemindersListsTests.swift index 49b95256..45c3560d 100644 --- a/Examples/RemindersTests/RemindersListsTests.swift +++ b/Examples/RemindersTests/RemindersListsTests.swift @@ -1,6 +1,5 @@ -import GRDB +import DependenciesTestSupport import InlineSnapshotTesting -import SharingGRDB import SnapshotTestingCustomDump import Testing diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index 0163a915..8faeceb1 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -1,8 +1,6 @@ import Dependencies import DependenciesTestSupport import InlineSnapshotTesting -import GRDB -import SharingGRDB import SnapshotTestingCustomDump import Testing @@ -10,156 +8,163 @@ import Testing extension BaseTestSuite { @MainActor + @Suite( + .snapshots(record: .missing) + ) struct SearchRemindersTests { @Dependency(\.defaultDatabase) var database @Test func basics() async throws { let model = SearchRemindersModel() - try await model.$reminders.load() - try await model.$completedCount.load() + try await model.$searchResults.load() - #expect(model.completedCount == 0) - assertInlineSnapshot(of: model.reminders, as: .customDump) { + #expect(model.searchResults.completedCount == 0) + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { """ [] """ } model.searchText = "Take" - try await model.$reminders.load() - try await model.$completedCount.load() - try await Task.sleep(for: .seconds(0.5)) - #expect(model.completedCount == 1) - assertInlineSnapshot(of: model.reminders, as: .customDump) { - """ - [ - [0]: SearchRemindersModel.Row( - isPastDue: false, - notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-00000000000A), - dueDate: Date(2009-02-17T23:31:30.000Z), - isCompleted: false, - isFlagged: false, + try await model.searchTask?.value + #expect(model.searchResults.completedCount == 1) + withKnownIssue( + "'@Fetch' introduces an escaping closure and loses the task-local dependency" + ) { + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, notes: "", - position: 8, - priority: .high, - remindersListID: UUID(00000000-0000-0000-0000-000000000001), - title: "Take out trash" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000001), - color: 3985191935, - position: 2, - title: "Family" - ), - tags: [] - ) - ] - """ + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-00000000000A), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: 3985191935, + position: 2, + title: "Family" + ), + tags: "", + title: "**Take** out trash" + ) + ] + """ + } } } @Test func showCompleted() async throws { let model = SearchRemindersModel() model.searchText = "Take" - await model.showCompletedButtonTapped() - try await Task.sleep(for: .seconds(0.1)) - try await model.$reminders.load() - try await model.$completedCount.load() + try await model.showCompletedButtonTapped() - assertInlineSnapshot(of: model.reminders, as: .customDump) { - """ - [ - [0]: SearchRemindersModel.Row( - isPastDue: false, - notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-00000000000A), - dueDate: Date(2009-02-17T23:31:30.000Z), - isCompleted: false, - isFlagged: false, + withKnownIssue( + "'@Fetch' introduces an escaping closure and loses the task-local dependency" + ) { + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, notes: "", - position: 8, - priority: .high, - remindersListID: UUID(00000000-0000-0000-0000-000000000001), - title: "Take out trash" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000001), - color: 3985191935, - position: 2, - title: "Family" + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-00000000000A), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: 3985191935, + position: 2, + title: "Family" + ), + tags: "", + title: "**Take** out trash" ), - tags: [] - ), - [1]: SearchRemindersModel.Row( - isPastDue: false, - notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-000000000006), - dueDate: Date(2008-08-07T23:31:30.000Z), - isCompleted: true, - isFlagged: false, + [1]: SearchRemindersModel.Row( + isPastDue: false, notes: "", - position: 4, - priority: nil, - remindersListID: UUID(00000000-0000-0000-0000-000000000000), - title: "Take a walk" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000000), - color: 1218047999, - position: 1, - title: "Personal" - ), - tags: [ - [0]: "car", - [1]: "kids", - [2]: "social" - ] - ) - ] - """ + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000006), + dueDate: Date(2008-08-07T23:31:30.000Z), + isCompleted: true, + isFlagged: false, + notes: "", + position: 4, + priority: nil, + remindersListID: UUID(00000000-0000-0000-0000-000000000000), + title: "Take a walk" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000000), + color: 1218047999, + position: 1, + title: "Personal" + ), + tags: "#car #kids #social", + title: "**Take** a walk" + ) + ] + """ + } } } @Test func deleteCompleted() async throws { let model = SearchRemindersModel() model.searchText = "Take" - await model.showCompletedButtonTapped() - try await Task.sleep(for: .seconds(0.1)) + try await model.showCompletedButtonTapped() model.deleteCompletedReminders() - try await model.$reminders.load() - try await model.$completedCount.load() - #expect(model.completedCount == 0) - assertInlineSnapshot(of: model.reminders, as: .customDump) { - """ - [ - [0]: SearchRemindersModel.Row( - isPastDue: false, - notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-00000000000A), - dueDate: Date(2009-02-17T23:31:30.000Z), - isCompleted: false, - isFlagged: false, + try await model.$searchResults.load() + #expect(model.searchResults.completedCount == 0) + withKnownIssue( + "'@Fetch' introduces an escaping closure and loses the task-local dependency" + ) { + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, notes: "", - position: 8, - priority: .high, - remindersListID: UUID(00000000-0000-0000-0000-000000000001), - title: "Take out trash" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000001), - color: 3985191935, - position: 2, - title: "Family" - ), - tags: [] - ) - ] - """ + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-00000000000A), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: 3985191935, + position: 2, + title: "Family" + ), + tags: "", + title: "**Take** out trash" + ) + ] + """ + } } } } diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index dc075816..2d66f05d 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -79,6 +79,7 @@ func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() + configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in #if DEBUG db.trace(options: .profile) { From 44518a470f9714adff27bab5bee1a8acba46ad32 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 13:08:04 -0700 Subject: [PATCH 490/581] wip --- Examples/Examples.xcodeproj/project.pbxproj | 8 +- Examples/Reminders/ReminderForm.swift | 2 +- Examples/Reminders/Reminders.entitlements | 2 +- Examples/Reminders/RemindersApp.swift | 42 +++++++- Examples/Reminders/RemindersDetail.swift | 56 ++++++++-- Examples/Reminders/RemindersListForm.swift | 109 +++++++++++++++++++- Examples/Reminders/RemindersLists.swift | 18 +++- Examples/Reminders/Schema.swift | 60 +++++++++-- Examples/Reminders/TagsForm.swift | 67 ++++++++++++ 9 files changed, 334 insertions(+), 30 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 99f41534..de94ce25 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -824,9 +824,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Reminders/Reminders.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Reminders/Preview Content\""; + DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -839,7 +841,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Reminders; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SQLiteData.RemindersDemo.Beta; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -851,9 +853,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Reminders/Reminders.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Reminders/Preview Content\""; + DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -866,7 +870,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Reminders; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SQLiteData.RemindersDemo.Beta; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index c99afc6f..6fd8a52f 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -211,7 +211,7 @@ struct ReminderFormPreview: PreviewProvider { let remindersList = try RemindersList.all.fetchOne(db)! return ( remindersList, - try Reminder.where { $0.remindersListID == remindersList.id }.fetchOne(db)! + try Reminder.where { $0.remindersListID.eq(remindersList.id) }.fetchOne(db)! ) } } diff --git a/Examples/Reminders/Reminders.entitlements b/Examples/Reminders/Reminders.entitlements index 21e4bff4..674bd5b6 100644 --- a/Examples/Reminders/Reminders.entitlements +++ b/Examples/Reminders/Reminders.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.co.pointfree.SQLiteData.demos.field-timestamps-2.Reminders + iCloud.co.pointfree.SQLiteData.RemindersDemo.Beta com.apple.developer.icloud-services diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index d5c55805..b1f3690f 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,15 +1,20 @@ +import CloudKit +import Combine +import Dependencies import SharingGRDB import SwiftUI +import UIKit @main struct RemindersApp: App { + @UIApplicationDelegateAdaptor var delegate: AppDelegate @Dependency(\.context) var context static let model = RemindersListsModel() init() { if context == .live { try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() + try $0.bootstrapDatabase() } } } @@ -24,3 +29,38 @@ struct RemindersApp: App { } } } + +class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + func windowScene( + _ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata + ) { + @Dependency(\.defaultSyncEngine) var syncEngine + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } +} diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 1f1ea51f..7cd4c3a2 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -7,6 +7,7 @@ import SwiftUINavigation @Observable class RemindersDetailModel: HashableObject { @ObservationIgnored @FetchAll var reminderRows: [Row] + @ObservationIgnored @FetchOne var coverImageData: Data? @ObservationIgnored @Shared var ordering: Ordering @ObservationIgnored @Shared var showCompleted: Bool @@ -22,7 +23,15 @@ class RemindersDetailModel: HashableObject { wrappedValue: detailType == .completed, .appStorage("show_completed_list_\(detailType.id)") ) - _reminderRows = FetchAll(remindersQuery) + _reminderRows = FetchAll(remindersQuery, animation: .default) + if let remindersListID = detailType.remindersList?.id { + _coverImageData = FetchOne( + RemindersListAsset + .where { $0.remindersListID.eq(remindersListID) } + .select(\.coverImage), + animation: .default + ) + } } func orderingButtonTapped(_ ordering: Ordering) async { @@ -152,15 +161,7 @@ struct RemindersDetailView: View { var body: some View { List { - VStack(alignment: .leading) { - GeometryReader { proxy in - Text(model.detailType.navigationTitle) - .font(.system(.largeTitle, design: .rounded, weight: .bold)) - .foregroundStyle(model.detailType.color) - .onAppear { navigationTitleHeight = proxy.size.height } - } - } - .listRowSeparator(.hidden) + header ForEach(model.reminderRows) { row in ReminderRow( color: model.detailType.color, @@ -250,6 +251,41 @@ struct RemindersDetailView: View { } .toolbarTitleDisplayMode(.inline) } + + @ViewBuilder + var header: some View { + if let coverImageData = model.coverImageData, let image = UIImage(data: coverImageData) { + ZStack { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(maxHeight: 200) + .clipped() + + GeometryReader { proxy in + Text(model.detailType.navigationTitle) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(model.detailType.color) + .padding() + .background(Color.black.opacity(0.6)) + .cornerRadius(10) + .padding() + .onAppear { navigationTitleHeight = proxy.size.height } + } + } + .listRowInsets(EdgeInsets()) + } else { + VStack(alignment: .leading) { + GeometryReader { proxy in + Text(model.detailType.navigationTitle) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(model.detailType.color) + .onAppear { navigationTitleHeight = proxy.size.height } + } + } + .listRowSeparator(.hidden) + } + } } extension RemindersDetailModel.DetailType { diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 7c9126d2..7f4f6dff 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -1,4 +1,5 @@ import IssueReporting +import PhotosUI import SharingGRDB import SwiftUI @@ -6,6 +7,9 @@ struct RemindersListForm: View { @Dependency(\.defaultDatabase) private var database @State var remindersList: RemindersList.Draft + @State var coverImageData: Data? + @State var photosPickerItem: PhotosPickerItem? + @State private var isPhotoPickerPresented = false @Environment(\.dismiss) var dismiss init(remindersList: RemindersList.Draft) { @@ -27,15 +31,69 @@ struct RemindersListForm: View { .clipShape(.buttonBorder) } ColorPicker("Color", selection: $remindersList.color) + ZStack(alignment: .topTrailing) { + ZStack { + if let coverImageData, + let uiImage = UIImage(data: coverImageData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(height: 150) + .clipped() + .cornerRadius(10) + } else { + Rectangle() + .fill(Color.secondary.opacity(0.1)) + .frame(height: 150) + .cornerRadius(10) + } + + Button("Select Cover Image") { + isPhotoPickerPresented = true + } + .padding() + .background(.ultraThinMaterial) + .clipShape(.capsule) + } + + if coverImageData != nil { + Button { + coverImageData = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .background(Color.white) + .clipShape(Circle()) + } + .padding(8) + } + } + .buttonStyle(.plain) } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem { Button("Save") { - withErrorReporting { - try database.write { db in - try RemindersList.upsert { remindersList } + Task { [remindersList, coverImageData] in + await withErrorReporting { + try await database.write { db in + let remindersListID = try RemindersList + .upsert { remindersList } + .returning(\.id) + .fetchOne(db) + guard let remindersListID + else { + reportIssue("No 'remindersListID'") + return + } + try RemindersListAsset.upsert { + RemindersListAsset.Draft( + remindersListID: remindersListID, + coverImage: coverImageData + ) + } .execute(db) + } } } dismiss() @@ -47,9 +105,54 @@ struct RemindersListForm: View { } } } + .photosPicker(isPresented: $isPhotoPickerPresented, selection: $photosPickerItem) + .onChange(of: photosPickerItem) { + Task { + await withErrorReporting { + if let photosPickerItem { + coverImageData = try await photosPickerItem.loadTransferable(type: Data.self) + .flatMap { resizedAndOptimizedImageData(from: $0) } + self.photosPickerItem = nil + } + } + } + } + .task { + guard let remindersListID = remindersList.id + else { return } + do { + coverImageData = try await database.read { db in + try RemindersListAsset + .where { $0.remindersListID.eq(remindersListID) } + .select(\.coverImage) + .fetchOne(db) ?? nil + } + } catch is CancellationError { + } catch { + reportIssue(error) + } + } } } +func resizedAndOptimizedImageData(from data: Data, maxWidth: CGFloat = 1000) -> Data? { + guard let image = UIImage(data: data) else { return nil } + + let originalSize = image.size + let scaleFactor = min(1, maxWidth / originalSize.width) + let newSize = CGSize( + width: originalSize.width * scaleFactor, + height: originalSize.height * scaleFactor + ) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1) + image.draw(in: CGRect(origin: .zero, size: newSize)) + let resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return resizedImage?.jpegData(compressionQuality: 0.8) +} + struct RemindersListFormPreviews: PreviewProvider { static var previews: some View { let _ = try! prepareDependencies { diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index e6daf88c..15863709 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -35,7 +35,7 @@ class RemindersListsModel { Reminder.select { Stats.Columns( allCount: $0.count(filter: !$0.isCompleted), - flaggedCount: $0.count(filter: $0.isFlagged), + flaggedCount: $0.count(filter: $0.isFlagged && !$0.isCompleted), scheduledCount: $0.count(filter: $0.isScheduled), todayCount: $0.count(filter: $0.isToday) ) @@ -72,6 +72,18 @@ class RemindersListsModel { ) } + func deleteTags(atOffsets offsets: IndexSet) { + withErrorReporting { + let tagTitles = offsets.map { tags[$0].title } + try database.write { db in + try Tag + .where { $0.title.in(tagTitles) } + .delete() + .execute(db) + } + } + } + func onAppear() { withErrorReporting { try Tips.configure() @@ -240,6 +252,7 @@ struct RemindersListsView: View { remindersList: state.remindersList ) } + .buttonStyle(.borderless) .foregroundStyle(.primary) } .onMove(perform: model.move(from:to:)) @@ -262,6 +275,9 @@ struct RemindersListsView: View { } .foregroundStyle(.primary) } + .onDelete { offsets in + model.deleteTags(atOffsets: offsets) + } } header: { Text("Tags") .font(.system(.title2, design: .rounded, weight: .bold)) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index f2eed39f..cf8b4f7e 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -20,7 +20,15 @@ struct RemindersList: Hashable, Identifiable { extension RemindersList.Draft: Identifiable {} @Table -struct Reminder: Codable, Equatable, Identifiable { +struct RemindersListAsset: Hashable, Identifiable { + @Column(primaryKey: true) + let remindersListID: RemindersList.ID + var coverImage: Data? + var id: RemindersList.ID { remindersListID } +} + +@Table +struct Reminder: Hashable, Identifiable { let id: UUID var dueDate: Date? var isCompleted = false @@ -41,7 +49,7 @@ struct Tag: Hashable, Identifiable { var id: String { title } } -enum Priority: Int, Codable, QueryBindable { +enum Priority: Int, QueryBindable { case low = 1 case medium case high @@ -89,12 +97,27 @@ struct ReminderText: StructuredQueries.FTS5 { let tags: String } +extension DependencyValues { + mutating func bootstrapDatabase() throws { + defaultDatabase = try Reminders.appDatabase() + defaultSyncEngine = try SyncEngine( + for: defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self + ) + } +} + func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in + try db.attachMetadatabase() #if DEBUG db.trace(options: .profile) { if context == .live { @@ -112,7 +135,12 @@ func appDatabase() throws -> any DatabaseWriter { context == .live ? URL.documentsDirectory.appending(component: "db.sqlite").path() : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - logger.info("open \(path)") + logger.debug( + """ + App database: + open "\(path)" + """ + ) database = try DatabasePool(path: path, configuration: configuration) } var migrator = DatabaseMigrator() @@ -125,9 +153,19 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "remindersLists" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "color" INTEGER NOT NULL DEFAULT \(raw: defaultListColor ?? 0), - "position" INTEGER NOT NULL DEFAULT 0, - "title" TEXT NOT NULL + "color" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT \(raw: defaultListColor ?? 0), + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "remindersListAssets" ( + "remindersListID" TEXT PRIMARY KEY NOT NULL + REFERENCES "remindersLists"("id") ON DELETE CASCADE, + "coverImage" BLOB ) STRICT """ ) @@ -137,13 +175,13 @@ func appDatabase() throws -> any DatabaseWriter { CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "dueDate" TEXT, - "isCompleted" INTEGER NOT NULL DEFAULT 0, - "isFlagged" INTEGER NOT NULL DEFAULT 0, - "notes" TEXT, - "position" INTEGER NOT NULL DEFAULT 0, + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isFlagged" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "notes" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE, - "title" TEXT NOT NULL + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ ) diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index ca5c9284..b1342219 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -1,15 +1,25 @@ import SharingGRDB import SwiftUI +import SwiftUINavigation struct TagsView: View { @Fetch(Tags()) var tags = Tags.Value() @Binding var selectedTags: [Tag] + @State var editingTag: Tag.Draft? + @State var tagTitle = "" + @Dependency(\.defaultDatabase) var database @Environment(\.dismiss) var dismiss var body: some View { Form { let selectedTagIDs = Set(selectedTags.map(\.id)) + Section { + Button("New tag") { + tagTitle = "" + editingTag = Tag.Draft() + } + } if !tags.top.isEmpty { Section { ForEach(tags.top, id: \.id) { tag in @@ -18,6 +28,14 @@ struct TagsView: View { selectedTags: $selectedTags, tag: tag ) + .swipeActions { + Button("Delete", role: .destructive) { + deleteButtonTapped(tag: tag) + } + Button("Edit") { + editButtonTapped(tag: tag) + } + } } } header: { Text("Top tags") @@ -31,10 +49,26 @@ struct TagsView: View { selectedTags: $selectedTags, tag: tag ) + .swipeActions { + Button("Delete", role: .destructive) { + deleteButtonTapped(tag: tag) + } + Button("Edit") { + editButtonTapped(tag: tag) + } + } } } } } + .alert(item: $editingTag) { item in + Text(item.title == nil ? "New tag" : "Edit tag") + } actions: { item in + TextField("Tag name", text: $tagTitle) + Button("Save") { + saveButtonTapped() + } + } .toolbar { ToolbarItem { Button("Done") { dismiss() } @@ -43,6 +77,39 @@ struct TagsView: View { .navigationTitle(Text("Tags")) } + func deleteButtonTapped(tag: Tag) { + withErrorReporting { + try database.write { db in + try Tag.find(tag.title).delete().execute(db) + } + } + } + + func editButtonTapped(tag: Tag) { + tagTitle = tag.title + editingTag = Tag.Draft(tag) + } + + func saveButtonTapped() { + defer { tagTitle = "" } + let tag = Tag(title: tagTitle) + withErrorReporting { + try database.write { db in + if let existingTagTitle = editingTag?.title { + selectedTags.removeAll(where: { $0.title == existingTagTitle }) + try Tag + .update { $0.title = tagTitle } + .where { $0.title.eq(existingTagTitle) } + .execute(db) + } else { + try Tag.insert(or: .ignore) { tag } + .execute(db) + } + } + selectedTags.append(tag) + } + } + struct Tags: FetchKeyRequest { func fetch(_ db: Database) throws -> Value { let top = From 42e102d19ab826f9ce2a49f1c4ed76d7dd7d1ea4 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 13:17:47 -0700 Subject: [PATCH 491/581] wip --- Examples/Reminders/RemindersDetail.swift | 25 ++++++++++++++++++ Examples/Reminders/RemindersListRow.swift | 32 ++++++++++++++++++++++- Examples/Reminders/RemindersLists.swift | 16 +++++++++--- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 7cd4c3a2..a4e94c0c 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,4 +1,5 @@ import CasePaths +import CloudKit import SharingGRDB import SwiftUI import SwiftUINavigation @@ -13,8 +14,10 @@ class RemindersDetailModel: HashableObject { let detailType: DetailType var isNewReminderSheetPresented = false + var sharedRecord: SharedRecord? @ObservationIgnored @Dependency(\.defaultDatabase) private var database + @ObservationIgnored @Dependency(\.defaultSyncEngine) private var syncEngine init(detailType: DetailType) { self.detailType = detailType @@ -68,6 +71,16 @@ class RemindersDetailModel: HashableObject { await updateQuery() } + func shareButtonTapped() async { + guard let remindersList = detailType.remindersList + else { return } + sharedRecord = await withErrorReporting { + try await syncEngine.share(record: remindersList) { share in + share[CKShare.SystemFieldKey.title] = remindersList.title + } + } + } + private func updateQuery() async { await withErrorReporting { try await $reminderRows.load(remindersQuery, animation: .default) @@ -194,6 +207,9 @@ struct RemindersDetailView: View { } } } + .sheet(item: $model.sharedRecord) { sharedRecord in + CloudSharingView(sharedRecord: sharedRecord) + } .toolbar { ToolbarItem(placement: .principal) { Text(model.detailType.navigationTitle) @@ -220,6 +236,15 @@ struct RemindersDetailView: View { } } ToolbarItem(placement: .primaryAction) { + HStack(alignment: .firstTextBaseline) { + if model.detailType.is(\.remindersList) { + Button { + Task { await model.shareButtonTapped() } + } label: { + Image(systemName: "square.and.arrow.up") + } + } + } Menu { Group { Menu { diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 9efeeff2..b6a46715 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -1,13 +1,16 @@ +import CloudKit import SharingGRDB import SwiftUI struct RemindersListRow: View { let remindersCount: Int let remindersList: RemindersList + var share: CKShare? @State var editList: RemindersList? @Dependency(\.defaultDatabase) private var database + @Dependency(\.defaultSyncEngine) private var syncEngine var body: some View { HStack { @@ -17,7 +20,14 @@ struct RemindersListRow: View { .background( Color.white.clipShape(Circle()).padding(4) ) - Text(remindersList.title) + VStack(alignment: .leading, spacing: 4) { + Text(remindersList.title) + if let shareMessage { + Text(shareMessage) + .font(.footnote) + .foregroundStyle(Color.secondary) + } + } Spacer() Text("\(remindersCount)") .foregroundStyle(.gray) @@ -48,6 +58,26 @@ struct RemindersListRow: View { .presentationDetents([.medium]) } } + + var shareMessage: String? { + guard let share + else { return nil } + if share.owner == share.currentUserParticipant { + let participantNames = share.participants + .filter { $0 != share.currentUserParticipant } + .compactMap { $0.userIdentity.nameComponents?.formatted() } + .joined(separator: ", ") + if !participantNames.isEmpty { + return "Shared with \(participantNames)" + } else { + return "Shared" + } + } else if let ownerName = share.owner.userIdentity.nameComponents?.formatted() { + return "Shared from \(ownerName)" + } else { + return nil + } + } } #Preview { diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 15863709..aa38ff5d 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -1,3 +1,4 @@ +import CloudKit import SharingGRDB import SwiftUI import SwiftUINavigation @@ -11,9 +12,15 @@ class RemindersListsModel { RemindersList .group(by: \.id) .order(by: \.position) - .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted } + .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted + } + .leftJoin(SyncMetadata.all) { $0.recordName.eq($2.recordName) } .select { - ReminderListState.Columns(remindersCount: $1.id.count(), remindersList: $0) + ReminderListState.Columns( + remindersCount: $1.id.count(), + remindersList: $0, + share: $2.share + ) }, animation: .default ) @@ -157,6 +164,8 @@ class RemindersListsModel { var id: RemindersList.ID { remindersList.id } var remindersCount: Int var remindersList: RemindersList + @Column(as: CKShare?.SystemFieldsRepresentation.self) + var share: CKShare? } @Selection @@ -249,7 +258,8 @@ struct RemindersListsView: View { } label: { RemindersListRow( remindersCount: state.remindersCount, - remindersList: state.remindersList + remindersList: state.remindersList, + share: state.share ) } .buttonStyle(.borderless) From 45e25655692b1028acc2c6163a4d7452a99902e4 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 26 Aug 2025 13:22:45 -0700 Subject: [PATCH 492/581] wip --- .github/workflows/ci.yml.orig | 67 ------- .gitignore | 1 + .../swiftpm/Package.resolved.orig | 164 ---------------- Package.resolved.orig | 178 ------------------ 4 files changed, 1 insertion(+), 409 deletions(-) delete mode 100644 .github/workflows/ci.yml.orig delete mode 100644 Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig delete mode 100644 Package.resolved.orig diff --git a/.github/workflows/ci.yml.orig b/.github/workflows/ci.yml.orig deleted file mode 100644 index 116c0de8..00000000 --- a/.github/workflows/ci.yml.orig +++ /dev/null @@ -1,67 +0,0 @@ -name: CI - -on: - push: - branches: - - main - - cloudkit - pull_request: - branches: - - '*' - workflow_dispatch: - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - library: - name: macOS - strategy: - matrix: - xcode: ['16.4'] - config: ['debug', 'release'] - runs-on: macos-15 - steps: - - uses: actions/checkout@v4 - - name: Select Xcode ${{ matrix.xcode }} - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - - name: Run ${{ matrix.config }} tests - run: swift test -c ${{ matrix.config }} - - examples: - name: Examples - strategy: - matrix: - xcode: ['16.4'] - config: ['debug'] - scheme: ['Reminders', 'CaseStudies', 'SyncUps'] - runs-on: macos-15 - steps: - - uses: actions/checkout@v4 - - name: Select Xcode ${{ matrix.xcode }} - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - - name: xcodebuild ${{ matrix.scheme }} - run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="${{ matrix.scheme }}" xcodebuild-raw - -<<<<<<< HEAD - # Bring back when GRDB successfully compiles on Linux again -======= - # NB: GRDB 7.6.1 does not currently build on Linux. ->>>>>>> origin/main - # linux: - # name: Linux - # strategy: - # matrix: - # swift: - # - '6.1' - # runs-on: ubuntu-latest - # container: swift:${{ matrix.swift }} - # steps: - # - uses: actions/checkout@v4 - # - name: Install Build Dependencies - # run: | - # apt-get update - # apt-get install -y libsqlite3-dev - # - name: Build - # run: swift build diff --git a/.gitignore b/.gitignore index 48a51863..d6a7bb00 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ xcuserdata/ DerivedData/ .swiftpm .netrc +*.orig *.sqlite *.xcresult diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig deleted file mode 100644 index 6d9d8ff4..00000000 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.orig +++ /dev/null @@ -1,164 +0,0 @@ -{ - "originHash" : "5ded5ba49617fcf43253f921c393a9829acb4bd0620c1d273ad236940406de92", - "pins" : [ - { - "identity" : "combine-schedulers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/combine-schedulers", - "state" : { - "revision" : "5928286acce13def418ec36d05a001a9641086f2", - "version" : "1.0.3" - } - }, - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift", - "state" : { - "revision" : "8ba1bc9a96afc731a000fd4136dd13a5a46297bd", - "version" : "7.6.1" - } - }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "9810c8d6c2914de251e072312f01d3bf80071852", - "version" : "1.7.1" - } - }, - { - "identity" : "swift-clocks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-clocks", - "state" : { - "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", - "version" : "1.0.6" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", - "version" : "1.3.1" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" - } - }, - { - "identity" : "swift-dependencies", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies", - "state" : { - "revision" : "eefcdaa88d2e2fd82e3405dfb6eb45872011a0b5", - "version" : "1.9.3" - } - }, - { - "identity" : "swift-identified-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections", - "state" : { - "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "4e89284c1966538109dc783497405bc680e9bc96", - "version" : "2.4.0" - } - }, - { - "identity" : "swift-perception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-perception", - "state" : { - "revision" : "7d3509c7f4de78ad3eb3d804e036fb62e3585141", - "version" : "2.0.5" - } - }, - { - "identity" : "swift-sharing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-sharing", - "state" : { - "revision" : "bddb52233714512f63e0dfa8cd0ee8203103f3b1", - "version" : "2.7.1" - } - }, - { - "identity" : "swift-snapshot-testing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", - "state" : { - "revision" : "d7e40607dcd6bc26543f5d9433103f06e0b28f8f", - "version" : "1.18.6" - } - }, - { - "identity" : "swift-structured-queries", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-structured-queries", - "state" : { -<<<<<<< HEAD - "revision" : "2468f4e34d909d11c053d773562c03ffea40a72e", - "version" : "0.12.1" -======= - "revision" : "b5b5a9ed9ff321f43a02f07394f2831eba5e11f2", - "version" : "0.13.0" ->>>>>>> origin/main - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" - } - }, - { - "identity" : "swift-tagged", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged", - "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", - "version" : "1.6.1" - } - } - ], - "version" : 3 -} diff --git a/Package.resolved.orig b/Package.resolved.orig deleted file mode 100644 index 1600bf9e..00000000 --- a/Package.resolved.orig +++ /dev/null @@ -1,178 +0,0 @@ -{ -<<<<<<< HEAD - "originHash" : "493bf6e940098a804cf8989b9f72881f75a5c49199e8c67acd3bcf701cf32b20", -======= - "originHash" : "d87ac3bfcdef05674d1d54c62277ab77a7f1a74d2b106a3e0a8eb5567e2ff1ed", ->>>>>>> origin/main - "pins" : [ - { - "identity" : "combine-schedulers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/combine-schedulers", - "state" : { - "revision" : "5928286acce13def418ec36d05a001a9641086f2", - "version" : "1.0.3" - } - }, - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift", - "state" : { - "revision" : "8ba1bc9a96afc731a000fd4136dd13a5a46297bd", - "version" : "7.6.1" - } - }, - { - "identity" : "swift-clocks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-clocks", - "state" : { - "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", - "version" : "1.0.6" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", - "version" : "1.3.1" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" - } - }, - { - "identity" : "swift-dependencies", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies", - "state" : { -<<<<<<< HEAD - "revision" : "eefcdaa88d2e2fd82e3405dfb6eb45872011a0b5", - "version" : "1.9.3" -======= - "revision" : "a501eebe552fd23691c560adf474fca2169ad8aa", - "version" : "1.9.4" ->>>>>>> origin/main - } - }, - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-plugin", - "state" : { - "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", - "version" : "1.4.5" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - }, - { - "identity" : "swift-identified-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections", - "state" : { - "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-perception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-perception", - "state" : { - "revision" : "7d3509c7f4de78ad3eb3d804e036fb62e3585141", - "version" : "2.0.5" - } - }, - { - "identity" : "swift-sharing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-sharing", - "state" : { -<<<<<<< HEAD - "revision" : "bddb52233714512f63e0dfa8cd0ee8203103f3b1", - "version" : "2.7.1" -======= - "revision" : "530c98ac2c3e393615616bd6d8fc604600c99f6f", - "version" : "2.7.2" ->>>>>>> origin/main - } - }, - { - "identity" : "swift-snapshot-testing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing", - "state" : { - "revision" : "d7e40607dcd6bc26543f5d9433103f06e0b28f8f", - "version" : "1.18.6" - } - }, - { - "identity" : "swift-structured-queries", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-structured-queries", - "state" : { -<<<<<<< HEAD - "revision" : "2468f4e34d909d11c053d773562c03ffea40a72e", - "version" : "0.12.1" -======= - "revision" : "b5b5a9ed9ff321f43a02f07394f2831eba5e11f2", - "version" : "0.13.0" ->>>>>>> origin/main - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" - } - }, - { - "identity" : "swift-tagged", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged", - "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", - "version" : "1.6.1" - } - } - ], - "version" : 3 -} From fbbd16aa725fa2a0d2ac8cf35b39ba0f60109809 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 28 Aug 2025 16:05:04 -0500 Subject: [PATCH 493/581] Fix upsert for tables that only have a primary key. --- Examples/Reminders/RemindersApp.swift | 28 ++++++++++++++++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 32 ++++++++++--------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index b1f3690f..5816f4f2 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -5,6 +5,8 @@ import SharingGRDB import SwiftUI import UIKit +import SwiftData + @main struct RemindersApp: App { @UIApplicationDelegateAdaptor var delegate: AppDelegate @@ -16,6 +18,32 @@ struct RemindersApp: App { try! prepareDependencies { try $0.bootstrapDatabase() } + + let container = CKContainer(identifier: ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier!) + +// Task { +// do { +// let record = CKRecord.init(recordType: "foo", recordID: CKRecord.ID.init(recordName: "bar")) +// let (saves, _) = try await container.privateCloudDatabase.modifyRecords(saving: [record], deleting: []) +// print(#line, saves) +// +// let fetchedRecord = try await container.privateCloudDatabase.record(for: record.recordID) +// print(fetchedRecord) +// +// let (_, deletes) = try await container.privateCloudDatabase.modifyRecords(saving: [], deleting: [record.recordID]) +// print(#line, deletes) +// +// let newRecord = CKRecord.init(recordType: "foo", recordID: CKRecord.ID.init(recordName: "bar")) +// let (newSaves, _) = try await container.privateCloudDatabase.modifyRecords(saving: [newRecord], deleting: []) +// print(#line, newSaves) +// +// print("!!!") +// +// } catch { +// print(error) +// print("-----") +// } +// } } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index b0d8ea73..8d98538e 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -971,7 +971,7 @@ deletions: [(recordID: CKRecord.ID, recordType: CKRecord.RecordType)] = [], syncEngine: any SyncEngineProtocol ) async { - let recordIDsByRecordType = OrderedDictionary( + let deletedRecordIDsByRecordType = OrderedDictionary( grouping: deletions.sorted { lhs, rhs in guard let lhsIndex = tablesByOrder[lhs.recordType], @@ -982,7 +982,7 @@ by: \.recordType ) .mapValues { $0.map(\.recordID) } - for (recordType, recordIDs) in recordIDsByRecordType { + for (recordType, recordIDs) in deletedRecordIDsByRecordType { let recordPrimaryKeys = recordIDs.compactMap(\.recordPrimaryKey) if let table = tablesByName[recordType] { func open(_: T.Type) { @@ -1953,11 +1953,7 @@ columnNames: some Collection ) -> QueryFragment { let allColumnNames = T.TableColumns.writableColumns.map(\.name) - guard - columnNames.contains(where: { $0 != T.columns.primaryKey.name }) - else { - return "" - } + let hasNonPrimaryKeyColumns = columnNames.contains(where: { $0 != T.columns.primaryKey.name }) var query: QueryFragment = "INSERT INTO \(T.self) (" query.append(allColumnNames.map { "\(quote: $0)" }.joined(separator: ", ")) query.append(") VALUES (") @@ -1974,17 +1970,23 @@ } .joined(separator: ", ") ) - query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO UPDATE SET ") - query.append( - columnNames - .filter { columnName in columnName != T.columns.primaryKey.name } - .map { + query.append(") ON CONFLICT(\(quote: T.columns.primaryKey.name)) DO ") + if hasNonPrimaryKeyColumns { + query.append("UPDATE SET ") + query.append( + columnNames + .filter { columnName in columnName != T.columns.primaryKey.name } + .map { """ \(quote: $0) = "excluded".\(quote: $0) """ - } - .joined(separator: ", ") - ) + } + .joined(separator: ", ") + ) + } else { + // TODO: write a unit test for this + query.append("NOTHING") + } return query } #endif From ef7f381840cf2b7c00fb1f3b9ad68dbbed4debe8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 29 Aug 2025 11:18:42 -0500 Subject: [PATCH 494/581] add some tests --- Examples/Reminders/RemindersApp.swift | 26 ------ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 1 - .../FetchRecordZoneChangesTests.swift | 88 +++++++++++++++++++ 3 files changed, 88 insertions(+), 27 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 5816f4f2..6640a5cd 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -18,32 +18,6 @@ struct RemindersApp: App { try! prepareDependencies { try $0.bootstrapDatabase() } - - let container = CKContainer(identifier: ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier!) - -// Task { -// do { -// let record = CKRecord.init(recordType: "foo", recordID: CKRecord.ID.init(recordName: "bar")) -// let (saves, _) = try await container.privateCloudDatabase.modifyRecords(saving: [record], deleting: []) -// print(#line, saves) -// -// let fetchedRecord = try await container.privateCloudDatabase.record(for: record.recordID) -// print(fetchedRecord) -// -// let (_, deletes) = try await container.privateCloudDatabase.modifyRecords(saving: [], deleting: [record.recordID]) -// print(#line, deletes) -// -// let newRecord = CKRecord.init(recordType: "foo", recordID: CKRecord.ID.init(recordName: "bar")) -// let (newSaves, _) = try await container.privateCloudDatabase.modifyRecords(saving: [newRecord], deleting: []) -// print(#line, newSaves) -// -// print("!!!") -// -// } catch { -// print(error) -// print("-----") -// } -// } } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 8d98538e..548199f3 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1984,7 +1984,6 @@ .joined(separator: ", ") ) } else { - // TODO: write a unit test for this query.append("NOTHING") } return query diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 929d7037..ef6161b8 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -442,5 +442,93 @@ extension BaseCloudKitTests { try #expect(RemindersList.all.fetchCount(db) == 0) } } + + @Test func receiveRecord_SingleFieldPrimaryKey() async throws { + let tagRecord = CKRecord(recordType: "tags", recordID: Tag.recordID(for: "weekend")) + tagRecord.encryptedValues["title"] = "weekend" + try await syncEngine.modifyRecords(scope: .private, saving: [tagRecord]).notify() + + try await userDatabase.read { db in + try #expect(Tag.all.fetchAll(db) == [Tag(title: "weekend")]) + } + } + + @Test func renamePrimaryKey() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Tag(title: "weekend") + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + ReminderTag(id: 1, reminderID: 1, tagID: "weekend") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try Tag.find("weekend").update { $0.title = "optional" }.execute(db) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminderTags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminderTags", + parent: nil, + share: nil, + id: 1, + reminderID: 1, + tagID: "optional" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ), + [3]: CKRecord( + recordID: CKRecord.ID(optional:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "optional" + ), + [4]: CKRecord( + recordID: CKRecord.ID(weekend:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "weekend" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } } From 52f47243ca04cb395965ef812cda68bceabfcfa7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 29 Aug 2025 13:30:05 -0700 Subject: [PATCH 495/581] Add `SyncEngine.{start,stop,isRunning}` (#142) * Add `SyncEngine.{start,stop}()` * wip * wip * wip * wip * wip * wip * wip * Remove unneeded sleeps * format --------- Co-authored-by: Brandon Williams --- Examples/Examples.xcodeproj/project.pbxproj | 2 + Examples/Reminders/RemindersLists.swift | 36 +- .../CloudKit/CloudKit+StructuredQueries.swift | 10 +- .../CloudKit/CloudKitSharing.swift | 2 +- .../CloudKit/Metadatabase.swift | 8 + ...ndingRecordZoneChange+MacroExpansion.swift | 41 ++ .../CloudKit/PendingRecordZoneChange.swift | 64 +++ .../SharingGRDBCore/CloudKit/SyncEngine.swift | 179 ++++--- .../CloudKitTests/CloudKitTests.swift | 10 +- .../CloudKitTests/RecordTypeTests.swift | 14 +- .../SyncEngineLifecycleTests.swift | 436 ++++++++++++++++++ .../CloudKitTests/SyncEngineTests.swift | 2 +- .../CloudKitTests/TriggerTests.swift | 5 +- .../Internal/BaseCloudKitTests.swift | 16 +- 14 files changed, 731 insertions(+), 94 deletions(-) create mode 100644 Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift create mode 100644 Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift create mode 100644 Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index de94ce25..e36be4b8 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -831,6 +831,7 @@ DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -860,6 +861,7 @@ DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Reminders/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index aa38ff5d..ccd2580d 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -12,7 +12,8 @@ class RemindersListsModel { RemindersList .group(by: \.id) .order(by: \.position) - .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted + .leftJoin(Reminder.all) { + $0.id.eq($1.remindersListID) && !$1.isCompleted } .leftJoin(SyncMetadata.all) { $0.recordName.eq($2.recordName) } .select { @@ -131,7 +132,7 @@ class RemindersListsModel { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) $0.position = - rest + rest .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in cases.when(id.element, then: id.offset) } @@ -143,13 +144,13 @@ class RemindersListsModel { } #if DEBUG - func seedDatabaseButtonTapped() { - withErrorReporting { - try database.write { db in - try db.seedSampleData() + func seedDatabaseButtonTapped() { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } } } - } #endif @CasePathable @@ -191,6 +192,8 @@ class RemindersListsModel { struct RemindersListsView: View { @Bindable var model: RemindersListsModel + @State var id = UUID() + @Dependency(\.defaultSyncEngine) var syncEngine var body: some View { List { @@ -305,7 +308,7 @@ struct RemindersListsView: View { .listStyle(.insetGrouped) .toolbar { #if DEBUG - ToolbarItem(placement: .automatic) { + ToolbarItem(placement: .automatic) { Menu { Button { model.seedDatabaseButtonTapped() @@ -313,6 +316,22 @@ struct RemindersListsView: View { Text("Seed data") Image(systemName: "leaf") } + Button { + if syncEngine.isRunning { + syncEngine.stop() + id = UUID() + } else { + Task { + await withErrorReporting { + try await syncEngine.start() + } + id = UUID() + } + } + } label: { + Text("\(syncEngine.isRunning ? "Stop" : "Start") synchronizing") + Image(systemName: syncEngine.isRunning ? "stop" : "play") + } } label: { Image(systemName: "ellipsis.circle") } @@ -368,6 +387,7 @@ struct RemindersListsView: View { .navigationDestination(item: $model.destination.detail) { detailModel in RemindersDetailView(model: detailModel) } + .id(id) } } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 40448072..726609fe 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -36,9 +36,7 @@ public struct _SystemFieldsRepresentation: QueryBindable, Quer } public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - guard let data = try Data?(decoder: &decoder) else { - throw QueryDecodingError.missingRequiredColumn - } + let data = try Data(decoder: &decoder) let coder = try NSKeyedUnarchiver(forReadingFrom: data) coder.requiresSecureCoding = true guard let queryOutput = Record(coder: coder) else { @@ -71,9 +69,7 @@ package struct _AllFieldsRepresentation: QueryBindable, QueryR } package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - guard let data = try Data?(decoder: &decoder) else { - throw QueryDecodingError.missingRequiredColumn - } + let data = try Data(decoder: &decoder) let coder = try NSKeyedUnarchiver(forReadingFrom: data) coder.requiresSecureCoding = true guard let queryOutput = Record(coder: coder) else { @@ -166,7 +162,7 @@ extension CKRecord { let asset = CKAsset(fileURL: URL(hash: newValue)) guard let fileURL = asset.fileURL, (self[key] as? CKAsset)?.fileURL != fileURL else { return false } - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try dataManager.save(Data(newValue), to: fileURL) } self[key] = asset diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index 50dd1928..a74e54b5 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -262,7 +262,7 @@ public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { @Dependency(\.defaultSyncEngine) var syncEngine - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try syncEngine.deleteShare(recordID: share.recordID) } didStopSharing() diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index fc4e2e90..e728a8bf 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -116,6 +116,14 @@ func defaultMetadatabase( ) .execute(db) } + migrator.registerMigration("Create PendingRecodZoneChanges Table") { db in + try SQLQueryExpression(""" + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( + "pendingRecordZoneChange" BLOB NOT NULL + ) STRICT + """) + .execute(db) + } try migrator.migrate(metadatabase) return metadatabase } diff --git a/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift new file mode 100644 index 00000000..6ea13bc7 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift @@ -0,0 +1,41 @@ +#if canImport(CloudKit) + import CloudKit + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PendingRecordZoneChange { + public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { + public typealias QueryValue = PendingRecordZoneChange + public let pendingRecordZoneChange = StructuredQueriesCore.TableColumn< + QueryValue, CKSyncEngine.PendingRecordZoneChange.DataRepresentation + >("pendingRecordZoneChange", keyPath: \QueryValue.pendingRecordZoneChange) + public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { + [QueryValue.columns.pendingRecordZoneChange] + } + public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { + [QueryValue.columns.pendingRecordZoneChange] + } + public var queryFragment: QueryFragment { + "\(self.pendingRecordZoneChange)" + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + nonisolated extension PendingRecordZoneChange: StructuredQueriesCore.Table { + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "sqlitedata_icloud_pendingRecordZoneChanges" + } + public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let pendingRecordZoneChange = try decoder.decode( + CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self + ) + guard let pendingRecordZoneChange else { + throw QueryDecodingError.missingRequiredColumn + } + self.pendingRecordZoneChange = pendingRecordZoneChange + } + } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift new file mode 100644 index 00000000..7db94d26 --- /dev/null +++ b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift @@ -0,0 +1,64 @@ +#if canImport(CloudKit) + import CloudKit + + // @Table("\(String.sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package struct PendingRecordZoneChange { + // @Column(as: CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self) + package let pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PendingRecordZoneChange { + package init(_ pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange) { + self.pendingRecordZoneChange = pendingRecordZoneChange + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension CKSyncEngine.PendingRecordZoneChange { + package struct DataRepresentation: QueryBindable, QueryRepresentable { + package var queryOutput: CKSyncEngine.PendingRecordZoneChange + + package init(queryOutput: CKSyncEngine.PendingRecordZoneChange) { + self.queryOutput = queryOutput + } + + package var queryBinding: StructuredQueriesCore.QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + switch queryOutput { + case .saveRecord(let recordID): + recordID.encode(with: archiver) + archiver.encode("saveRecord", forKey: "changeType") + case .deleteRecord(let recordID): + recordID.encode(with: archiver) + archiver.encode("deleteRecord", forKey: "changeType") + @unknown default: + return .invalid(BindingError()) + } + return archiver.encodedData.queryBinding + } + + package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + let data = try Data(decoder: &decoder) + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let recordID = CKRecord.ID(coder: coder) else { + throw DecodingError() + } + let changeType = coder.decodeObject(of: NSString.self, forKey: "changeType") as? String + switch changeType { + case "saveRecord": + self.init(queryOutput: .saveRecord(recordID)) + case "deleteRecord": + self.init(queryOutput: .deleteRecord(recordID)) + default: + throw DecodingError() + } + } + } + + private struct DecodingError: Error {} + private struct BindingError: Error {} + } +#endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 548199f3..2851a3d1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -33,6 +33,7 @@ privateTables: repeat (each T2).Type, containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), + startImmediately: Bool = true, logger: Logger = isTesting ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") ) throws @@ -60,7 +61,7 @@ let sharedDatabase = MockCloudDatabase(databaseScope: .shared) try self.init( container: MockCloudContainer( - containerIdentifier: containerIdentifier ?? "co.pointfree.sqlitedata-icloud.testing", + containerIdentifier: containerIdentifier ?? "iCloud.co.pointfree.SQLiteData.Tests", privateCloudDatabase: privateDatabase, sharedCloudDatabase: sharedDatabase ), @@ -84,10 +85,10 @@ tables: allTables, privateTables: allPrivateTables ) - _ = try setUpSyncEngine( - userDatabase: userDatabase, - metadatabase: metadatabase - ) + try setUpSyncEngine() + if startImmediately { + _ = try start() + } return } @@ -138,10 +139,10 @@ tables: allTables, privateTables: allPrivateTables ) - _ = try setUpSyncEngine( - userDatabase: userDatabase, - metadatabase: metadatabase - ) + try setUpSyncEngine() + if startImmediately { + _ = try start() + } } package init( @@ -208,14 +209,7 @@ @TaskLocal package static var _isSynchronizingChanges = false - package func setUpSyncEngine() async throws { - try await setUpSyncEngine(userDatabase: userDatabase, metadatabase: metadatabase)?.value - } - - nonisolated package func setUpSyncEngine( - userDatabase: UserDatabase, - metadatabase: any DatabaseReader - ) throws -> Task? { + nonisolated package func setUpSyncEngine() throws { try userDatabase.write { db in let attachedMetadatabasePath: String? = try SQLQueryExpression( @@ -238,7 +232,7 @@ ), debugDescription: """ Metadatabase attached in 'prepareDatabase' does not match metadatabase prepared in \ - 'SyncEngine.init'. Are the CloudKit container identifiers different? + 'SyncEngine.init'. Are different CloudKit container identifiers being provided? """ ) } @@ -269,7 +263,27 @@ ) } } + } + public func start() async throws { + try await start().value + } + + public func stop() { + guard isRunning else { return } + syncEngines.withValue { + $0 = SyncEngines() + } + } + + public var isRunning: Bool { + syncEngines.withValue { + $0.isRunning + } + } + + private func start() throws -> Task { + guard !isRunning else { return Task {} } let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) syncEngines.withValue { $0 = SyncEngines( @@ -337,6 +351,28 @@ previousRecordTypeByTableName: [String: RecordType], currentRecordTypeByTableName: [String: RecordType] ) async throws { + let pendingRecordZoneChanges = try await userDatabase.read { db in + try PendingRecordZoneChange + .select(\.pendingRecordZoneChange) + .fetchAll(db) + } + let changesByIsPrivate = Dictionary.init(grouping: pendingRecordZoneChanges) { + switch $0 { + case .deleteRecord(let recordID), .saveRecord(let recordID): + recordID.zoneID.ownerName == CKCurrentUserDefaultName + @unknown default: + false + } + } + syncEngines.withValue { + $0.private?.state.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) + $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) + } + + try await userDatabase.write { db in + try PendingRecordZoneChange.delete().execute(db) + } + let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in previousRecordTypeByTableName[tableName] == nil } @@ -402,9 +438,9 @@ } } - package func tearDownSyncEngine() async throws { - try await userDatabase.write { db in - for table in self.tables { + package func tearDownSyncEngine() throws { + try userDatabase.write { db in + for table in tables { try table.dropTriggers(db: db) } for trigger in SyncMetadata.callbackTriggers.reversed() { @@ -415,8 +451,6 @@ db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .syncEngineIsSynchronizingChanges) db.remove(function: .datetime) - } - try await userDatabase.write { db in // TODO: Do an `.erase()` + re-migrate try SyncMetadata.delete().execute(db) try RecordType.delete().execute(db) @@ -425,8 +459,8 @@ } } - func deleteLocalData() async throws { - try await tearDownSyncEngine() + func deleteLocalData() throws { + try tearDownSyncEngine() withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in for table in tables { @@ -439,31 +473,38 @@ } } } - try await setUpSyncEngine() + try setUpSyncEngine() } func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { let zoneID = zoneID ?? defaultZone.zoneID + let change = CKSyncEngine.PendingRecordZoneChange.saveRecord( + CKRecord.ID( + recordName: recordName, + zoneID: zoneID + ) + ) + guard isRunning else { + Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + try PendingRecordZoneChange + .insert { PendingRecordZoneChange(change) } + .execute(db) + } + } + } + return + } + let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - syncEngine?.state.add( - pendingRecordZoneChanges: [ - .saveRecord( - CKRecord.ID( - recordName: recordName, - zoneID: zoneID - ) - ) - ] - ) + syncEngine?.state.add(pendingRecordZoneChanges: [change]) } func didDelete(recordName: String, zoneID: CKRecordZone.ID?, share: CKShare?) { let zoneID = zoneID ?? defaultZone.zoneID - let syncEngine = self.syncEngines.withValue { - zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared - } var changes: [CKSyncEngine.PendingRecordZoneChange] = [ .deleteRecord( CKRecord.ID( @@ -475,6 +516,22 @@ if let share { changes.append(.deleteRecord(share.recordID)) } + guard isRunning else { + Task { [changes] in + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + try PendingRecordZoneChange + .insert { changes.map { PendingRecordZoneChange($0) } } + .execute(db) + } + } + } + return + } + + let syncEngine = self.syncEngines.withValue { + zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } syncEngine?.state.add(pendingRecordZoneChanges: changes) } @@ -702,7 +759,7 @@ } func open(_: T.Type) async -> CKRecord? { let row = - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.read { db in try T .where { @@ -774,7 +831,7 @@ let deletedRecordNames = deletedRecordIDs.map(\.recordName) let (metadataOfDeletions, recordsWithRoot): ([SyncMetadata], [RecordWithRoot]) = - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.read { db in let metadataOfDeletions = try SyncMetadata.where { $0.recordName.in(deletedRecordNames) @@ -836,7 +893,7 @@ ) } - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.in(deletedRecordNames) } @@ -866,8 +923,8 @@ } } case .signOut, .switchAccounts: - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await deleteLocalData() + withErrorReporting(.sqliteDataCloudKitFailure) { + try deleteLocalData() } @unknown default: break @@ -1007,7 +1064,7 @@ open(table) } else if recordType == CKRecord.SystemType.share { for recordID in recordIDs { - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try deleteShare(recordID: recordID) } } @@ -1085,7 +1142,7 @@ group.addTask { switch share { case .share(let share): - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await self.cacheShare(share) } case .reference(let shareReference): @@ -1093,7 +1150,7 @@ let record = try? await syncEngine.database.record(for: shareReference.recordID), let share = record as? CKShare else { return } - await withErrorReporting { + await withErrorReporting(.sqliteDataCloudKitFailure) { try await self.cacheShare(share) } } @@ -1121,7 +1178,7 @@ } for (failedRecord, error) in failedRecordSaves { func clearServerRecord() { - withErrorReporting { + withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.write { db in try SyncMetadata .where { $0.recordName.eq(failedRecord.recordID.recordName) } @@ -1592,7 +1649,7 @@ extension String { package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" - fileprivate static let sqliteDataCloudKitFailure = "SharingGRDB CloudKit Failure" + package static let sqliteDataCloudKitFailure = "SQLiteData CloudKit Failure" } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -1603,8 +1660,8 @@ ) throws -> URL { guard let databaseURL = URL(string: databasePath) else { - struct InvalidDatabsePath: Error {} - throw InvalidDatabsePath() + struct InvalidDatabasePath: Error {} + throw InvalidDatabasePath() } guard !databaseURL.isInMemory else { @@ -1629,31 +1686,31 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package struct SyncEngines { - let _private: (any SyncEngineProtocol)? - let _shared: (any SyncEngineProtocol)? + private let rawValue: (private: any SyncEngineProtocol, shared: any SyncEngineProtocol)? init() { - _private = nil - _shared = nil + rawValue = nil } init(private: any SyncEngineProtocol, shared: any SyncEngineProtocol) { - self._private = `private` - self._shared = shared + rawValue = (`private`, shared) + } + var isRunning: Bool { + rawValue != nil } package var `private`: (any SyncEngineProtocol)? { - guard let _private + guard let `private` = rawValue?.private else { reportIssue("Private sync engine has not been set.") return nil } - return _private + return `private` } package var `shared`: (any SyncEngineProtocol)? { - guard let _shared + guard let `shared` = rawValue?.shared else { reportIssue("Shared sync engine has not been set.") return nil } - return _shared + return `shared` } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 06757dc4..197e0582 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -489,7 +489,7 @@ extension BaseCloudKitTests { let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 1) } - try await syncEngine.tearDownSyncEngine() + try syncEngine.tearDownSyncEngine() try await self.userDatabase.userRead { db in let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 #expect(metadataCount == 0) @@ -498,8 +498,9 @@ extension BaseCloudKitTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDownAndReSetUp() async throws { - try await syncEngine.tearDownSyncEngine() - try await syncEngine.setUpSyncEngine() + try syncEngine.tearDownSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() try await userDatabase.userWrite { db in try db.seed { @@ -538,6 +539,7 @@ extension BaseCloudKitTests { #expect(metadata != nil) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addAndRemoveFunctions() async throws { let query = #sql( @@ -563,7 +565,7 @@ extension BaseCloudKitTests { ] """ } - try await syncEngine.tearDownSyncEngine() + try syncEngine.tearDownSyncEngine() assertInlineSnapshot( of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 44272b58..14cddc17 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -453,7 +453,7 @@ extension BaseCloudKitTests { } @Test func tearDown() async throws { - try await syncEngine.tearDownSyncEngine() + try syncEngine.tearDownSyncEngine() try await userDatabase.userRead { db in try #expect(RecordType.all.fetchAll(db) == []) } @@ -463,8 +463,10 @@ extension BaseCloudKitTests { let recordTypes = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) } - try await syncEngine.tearDownSyncEngine() - try await syncEngine.setUpSyncEngine() + syncEngine.stop() + try syncEngine.tearDownSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() let recordTypesAfterReSetup = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) } @@ -475,7 +477,8 @@ extension BaseCloudKitTests { let recordTypes = try await userDatabase.userRead { db in try RecordType.order(by: \.tableName).fetchAll(db) } - try await syncEngine.tearDownSyncEngine() + syncEngine.stop() + try syncEngine.tearDownSyncEngine() try await userDatabase.userWrite { db in try #sql( """ @@ -484,7 +487,8 @@ extension BaseCloudKitTests { ) .execute(db) } - try await syncEngine.setUpSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() let recordTypesAfterMigration = try await userDatabase.userRead { db in try RecordType.order(by: \.tableName).fetchAll(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift new file mode 100644 index 00000000..0ef1aaf9 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -0,0 +1,436 @@ +import CloudKit +import DependenciesTestSupport +import InlineSnapshotTesting +import OrderedCollections +import SharingGRDB +import SnapshotTesting +import SnapshotTestingCustomDump +import Testing +import os + +extension BaseCloudKitTests { + @Suite + struct SyncEngineLifecycleTests { + @MainActor + @Suite + final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked Sendable + { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func stopAndReStart() async throws { + syncEngine.stop() + + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 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)) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test func writeStopDeleteStart() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title += "!" }.execute(db) + } + + try await userDatabase.read { db in + try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) + try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal!" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.read { db in + try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) + } + } + } + + @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: 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" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + isCompleted: 0, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + @Test func extenalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() + async throws + { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { remindersList } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + + @MainActor + final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked Sendable + { + init() async throws { + try await super.init(startImmediately: false) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func writeAndThenStart() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 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)) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await syncEngine.start() + await signIn() + try await syncEngine.processPendingDatabaseChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index acf9d5be..3d83ba55 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -86,7 +86,7 @@ extension BaseCloudKitTests { attachedPath: "/private/tmp/.db.metadata-iCloud.co.pointfree.sqlite", syncEngineConfiguredPath: "/tmp/.db.metadata-iCloud.co.point-free.sqlite" ), - debugDescription: "Metadatabase attached in \'prepareDatabase\' does not match metadatabase prepared in \'SyncEngine.init\'. Are the CloudKit container identifiers different?" + debugDescription: "Metadatabase attached in \'prepareDatabase\' does not match metadatabase prepared in \'SyncEngine.init\'. Are different CloudKit container identifiers being provided?" ) """# } diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index a7baa5a1..e18de701 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -982,7 +982,7 @@ extension BaseCloudKitTests { } #endif - try await syncEngine.tearDownSyncEngine() + try syncEngine.tearDownSyncEngine() let triggersAfterTearDown = try await userDatabase.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) } @@ -992,7 +992,8 @@ extension BaseCloudKitTests { """ } - try await syncEngine.setUpSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() let triggersAfterReSetUp = try await userDatabase.userWrite { db in try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) } diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 00b5419b..195b2c9a 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -28,7 +28,8 @@ 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 } + setUpUserDatabase: @Sendable (UserDatabase) async throws -> Void = { _ in }, + startImmediately: Bool = true ) async throws { let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" @@ -64,9 +65,10 @@ class BaseCloudKitTests: @unchecked Sendable { ], privateTables: [ RemindersListPrivate.self - ] + ], + startImmediately: startImmediately ) - if accountStatus == .available { + if startImmediately, accountStatus == .available { await syncEngine.handleEvent( .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), syncEngine: syncEngine.private @@ -136,7 +138,8 @@ extension SyncEngine { container: any CloudContainer, userDatabase: UserDatabase, tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] + privateTables: [any PrimaryKeyedTable.Type] = [], + startImmediately: Bool = true ) async throws { try self.init( container: container, @@ -160,7 +163,10 @@ extension SyncEngine { tables: tables, privateTables: privateTables ) - try await setUpSyncEngine(userDatabase: userDatabase, metadatabase: metadatabase)?.value + try setUpSyncEngine() + if startImmediately { + try await start() + } } } From 879d79f46129edf95210363ab0a040c150e154ff Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 1 Sep 2025 13:50:51 -0700 Subject: [PATCH 496/581] CloudKit: Compile tests on non-macOS platforms (#144) * CloudKit: Compile tests on non-macOS platforms * Bump GRDB requirement * wip * wip * wip --- Package.swift | 2 +- Package@swift-6.0.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 5 +- .../Internal/DataManager.swift | 11 ++--- .../Internal/UserDatabase.swift | 49 +++++++------------ .../CloudKitTests/AccountLifecycleTests.swift | 7 ++- .../CloudKitTests/CloudKitTests.swift | 2 + .../FetchRecordZoneChangesTests.swift | 7 +++ .../FetchedDatabaseChangesTests.swift | 2 + .../ForeignKeyConstraintTests.swift | 8 +++ .../CloudKitTests/MergeConflictTests.swift | 6 +++ .../CloudKitTests/MetadataTests.swift | 4 ++ .../MockCloudDatabaseTests.swift | 4 ++ .../CloudKitTests/NewTableSyncTests.swift | 5 +- .../NextRecordZoneChangeBatchTests.swift | 9 +++- .../CloudKitTests/RecordTypeTests.swift | 3 ++ .../SyncEngineLifecycleTests.swift | 8 ++- .../CloudKitTests/SyncEngineTests.swift | 4 ++ .../SyncEngineValidationTests.swift | 6 ++- .../CloudKitTests/UserlandTests.swift | 1 + .../Internal/BaseCloudKitTests.swift | 13 ++++- .../Internal/CloudKit+CustomDump.swift | 6 ++- .../Internal/CloudKitTestHelpers.swift | 4 +- Tests/SharingGRDBTests/Internal/Schema.swift | 2 +- .../Internal/UserDatabaseHelpers.swift | 8 ++- Tests/SharingGRDBTests/SharingGRDBTests.swift | 1 + 26 files changed, 119 insertions(+), 60 deletions(-) diff --git a/Package.swift b/Package.swift index 6e452941..f9db8414 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .default(enabledTraits: ["SharingGRDBTagged"]), ], dependencies: [ - .package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"), + .package(url: "https://github.com/groue/GRDB.swift", from: "7.6.0"), .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index ca92907f..125a8737 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -29,7 +29,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"), + .package(url: "https://github.com/groue/GRDB.swift", from: "7.6.0"), .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 2851a3d1..ad271a90 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1873,8 +1873,9 @@ throw SyncEngine.SchemaError( reason: .triggersWithoutSynchronizationCheck(invalidTriggers), debugDescription: """ - Triggers must include '\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()' \ - check: \(triggers.map { "'\($0)'" }.joined(separator: ", ")). + Triggers must include 'SyncEngine.isSynchronizingChanges()' \ + ('\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()') \ + check: \(invalidTriggers.map { "'\($0)'" }.joined(separator: ", ")). """ ) } diff --git a/Sources/SharingGRDBCore/Internal/DataManager.swift b/Sources/SharingGRDBCore/Internal/DataManager.swift index 96e6a1db..68dd2640 100644 --- a/Sources/SharingGRDBCore/Internal/DataManager.swift +++ b/Sources/SharingGRDBCore/Internal/DataManager.swift @@ -1,14 +1,12 @@ import Dependencies import Foundation -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) package protocol DataManager: Sendable { func load(_ url: URL) throws -> Data func save(_ data: Data, to url: URL) throws var temporaryDirectory: URL { get } } -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct LiveDataManager: DataManager { func load(_ url: URL) throws -> Data { try Data(contentsOf: url) @@ -17,11 +15,10 @@ struct LiveDataManager: DataManager { try data.write(to: url) } var temporaryDirectory: URL { - .temporaryDirectory + URL(fileURLWithPath: NSTemporaryDirectory()) } } -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) package struct InMemoryDataManager: DataManager { package let storage = LockIsolated<[URL: Data]>([:]) @@ -43,11 +40,10 @@ package struct InMemoryDataManager: DataManager { } package var temporaryDirectory: URL { - URL(filePath: "/") + URL(fileURLWithPath: "/") } } -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) private enum DataManagerKey: DependencyKey { static var liveValue: any DataManager { LiveDataManager() @@ -58,8 +54,7 @@ private enum DataManagerKey: DependencyKey { } extension DependencyValues { - @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - package var dataManager: DataManager { + package var dataManager: DataManager { get { self[DataManagerKey.self] } set { self[DataManagerKey.self] = newValue } } diff --git a/Sources/SharingGRDBCore/Internal/UserDatabase.swift b/Sources/SharingGRDBCore/Internal/UserDatabase.swift index 03c57312..29a448be 100644 --- a/Sources/SharingGRDBCore/Internal/UserDatabase.swift +++ b/Sources/SharingGRDBCore/Internal/UserDatabase.swift @@ -1,7 +1,6 @@ import Dependencies import GRDB -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package struct UserDatabase { private let database: any DatabaseWriter package init(database: any DatabaseWriter) { @@ -16,60 +15,48 @@ package struct UserDatabase { database.configuration } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package func write( - _ updates: @escaping @Sendable (Database) throws -> T + _ updates: @Sendable (Database) throws -> T ) async throws -> T { - try await withEscapedDependencies { dependencies in - try await database.write { db in - try SyncEngine.$_isSynchronizingChanges.withValue(true) { - try dependencies.yield { - try updates(db) - } - } + try await database.write { db in + try SyncEngine.$_isSynchronizingChanges.withValue(true) { + try updates(db) } } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package func read( - _ updates: @escaping @Sendable (Database) throws -> T + _ updates: @Sendable (Database) throws -> T ) async throws -> T { - try await withEscapedDependencies { dependencies in - try await database.read { db in - try SyncEngine.$_isSynchronizingChanges.withValue(true) { - try dependencies.yield { - try updates(db) - } - } + try await database.read { db in + try SyncEngine.$_isSynchronizingChanges.withValue(true) { + try updates(db) } } } @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package func write( _ updates: (Database) throws -> T ) throws -> T { - try withEscapedDependencies { dependencies in - try database.write { db in - try SyncEngine.$_isSynchronizingChanges.withValue(true) { - try dependencies.yield { - try updates(db) - } - } + try database.write { db in + try SyncEngine.$_isSynchronizingChanges.withValue(true) { + try updates(db) } } } @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package func read( _ updates: (Database) throws -> T ) throws -> T { - try withEscapedDependencies { dependencies in - try database.read { db in - try SyncEngine.$_isSynchronizingChanges.withValue(true) { - try dependencies.yield { - try updates(db) - } - } + try database.read { db in + try SyncEngine.$_isSynchronizingChanges.withValue(true) { + try updates(db) } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift index ebe46262..9efd74c3 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift @@ -10,6 +10,7 @@ import Testing extension BaseCloudKitTests { @MainActor final class AccountLifecycleTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func signOutClearsUserDatabaseAndMetadatabase() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -34,8 +35,8 @@ extension BaseCloudKitTests { }() } - @Test(.accountStatus(.noAccount)) - func signInUploadsLocalRecordsToCloudKit() async throws { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.accountStatus(.noAccount)) func signInUploadsLocalRecordsToCloudKit() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") @@ -107,6 +108,7 @@ extension BaseCloudKitTests { @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 @@ -120,6 +122,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func doNotUploadExistingDataToCloudKitWhenSignedOut() { } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index 197e0582..aca71edd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -945,6 +945,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func cascadingDeletionOrder() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -1012,6 +1013,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func generatedColumns() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index ef6161b8..b6030b58 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -11,6 +11,7 @@ extension BaseCloudKitTests { @MainActor @Suite final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func saveExtraFieldsToSyncMetadata() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -105,6 +106,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteChangeParentRelationship() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -201,6 +203,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveNewRecordFromCloudKit() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, @@ -284,6 +287,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveNewRecordFromCloudKit_ChildBeforeParent() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, @@ -415,6 +419,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteMultipleRecords() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -443,6 +448,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveRecord_SingleFieldPrimaryKey() async throws { let tagRecord = CKRecord(recordType: "tags", recordID: Tag.recordID(for: "weekend")) tagRecord.encryptedValues["title"] = "weekend" @@ -453,6 +459,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func renamePrimaryKey() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift index e13f3781..673f6050 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift @@ -11,6 +11,7 @@ extension BaseCloudKitTests { @MainActor @Suite final class FetchedDatabaseChangesTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteSyncEngineZone() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -42,6 +43,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func deleteSyncEngineZone_EncryptedDataReset() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index bf09dd5b..ca260633 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -9,6 +9,7 @@ import Testing extension BaseCloudKitTests { @MainActor final class ForeignKeyConstraintTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveChildBeforeParent() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, @@ -153,6 +154,7 @@ extension BaseCloudKitTests { * Remote deletes record B and C. * C should be deleted from local client. */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteCreatesRecordABC_localReceivesAC_remoteDeletesBC() async throws { let modelARecord = CKRecord(recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1)) let modelBRecord = CKRecord(recordType: ModelB.tableName, recordID: ModelB.recordID(for: 1)) @@ -207,6 +209,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test( """ 1) Receive child record without parent. @@ -293,6 +296,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveChild_Relaunch_ReceiveParent() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, @@ -437,6 +441,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test( """ Remote changes parent relationship to an unknown record which is synchronized later. @@ -579,6 +584,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func changeParentRelationship_RemotelyThenLocally() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -659,6 +665,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func changeParentRelationship_RemoteFirstEdited_LocalSecondEdited_SendBatch_ReceiveCloudKit() async throws @@ -759,6 +766,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func cascadingDeletes() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 36495d03..9dcb334a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -10,6 +10,7 @@ import Testing extension BaseCloudKitTests { @MainActor @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -164,6 +165,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func serverRecordUpdatedBeforeClientRecord() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -318,6 +320,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func serverAndClientEditDifferentFields() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -387,6 +390,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func serverRecordEditedAfterClientButProcessedBeforeClient() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -463,6 +467,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func serverRecordEditedAndProcessedBeforeClient() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -532,6 +537,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func serverRecordEditedBeforeClientButProcessedAfterClient() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index aebbc811..cc8e54f6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -134,6 +134,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func noParentRecordForRecordsWithMultipleForeignKeys() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -204,6 +205,7 @@ extension BaseCloudKitTests { #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 { @@ -230,6 +232,7 @@ extension BaseCloudKitTests { ) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func parentRecordType() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -257,6 +260,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func parentRecordPrimaryKey() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index 7751074d..cc143b6f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -10,6 +10,7 @@ import Testing extension BaseCloudKitTests { @MainActor final class MockCloudDatabaseTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) init() async throws { try await super.init() let (saveZoneResults, _) = try syncEngine.private.database.modifyRecordZones( @@ -374,6 +375,7 @@ extension BaseCloudKitTests { #expect(error == CKError(.notAuthenticated)) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func incorrectlyCreatingNewRecordIdentity() async throws { let record1 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) _ = try syncEngine.modifyRecords(scope: .private, saving: [record1]) @@ -389,6 +391,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func saveShareWithoutRootRecord() async throws { let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) @@ -402,6 +405,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func saveShareAndRootThenSaveShareAlone() async throws { let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 387cac19..72e4ddbc 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -9,6 +9,7 @@ import Testing 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 @@ -22,8 +23,8 @@ extension BaseCloudKitTests { ) } - @Test - func initialSync() async throws { + @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) { """ diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index b8b23725..8c5056b0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -9,6 +9,7 @@ import Testing extension BaseCloudKitTests { @MainActor final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func noMetadataForRecord() async throws { syncEngine.private.state.add( pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: 1))] @@ -31,6 +32,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func nonExistentTable() async throws { try await userDatabase.userWrite { db in try SyncMetadata.insert { @@ -60,6 +62,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func metadataRowWithNoCorrespondingRecordRow() async throws { try await userDatabase.userWrite { db in try SyncMetadata.insert { @@ -89,6 +92,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func saveRecord() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -122,8 +126,8 @@ extension BaseCloudKitTests { } } - @Test - func saveRecordWithParent() async throws { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveRecordWithParent() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") @@ -167,6 +171,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func savePrivateRecord() async throws { try await userDatabase.userWrite { db in try db.seed { diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift index 14cddc17..2893c8bb 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift @@ -452,6 +452,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDown() async throws { try syncEngine.tearDownSyncEngine() try await userDatabase.userRead { db in @@ -459,6 +460,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func resetUp() async throws { let recordTypes = try await userDatabase.userRead { db in try RecordType.all.fetchAll(db) @@ -473,6 +475,7 @@ extension BaseCloudKitTests { expectNoDifference(recordTypes, recordTypesAfterReSetup) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func migration() async throws { let recordTypes = try await userDatabase.userRead { db in try RecordType.order(by: \.tableName).fetchAll(db) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift index 0ef1aaf9..1d757710 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -88,6 +88,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func writeStopDeleteStart() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -121,6 +122,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -176,6 +178,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { let externalZoneID = CKRecordZone.ID( zoneName: "external.zone", @@ -245,7 +248,8 @@ extension BaseCloudKitTests { } } - @Test func extenalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func externalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { let externalZoneID = CKRecordZone.ID( @@ -290,6 +294,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { let remindersList = RemindersList(id: 1, title: "Personal") try await userDatabase.userWrite { db in @@ -355,6 +360,7 @@ extension BaseCloudKitTests { @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) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift index 3d83ba55..f7227cf9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift @@ -10,6 +10,7 @@ import Testing extension BaseCloudKitTests { @MainActor final class SyncEngineTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func inMemory() throws { #expect(URL(string: "")?.isInMemory == nil) #expect(URL(string: ":memory:")?.isInMemory == true) @@ -18,6 +19,7 @@ extension BaseCloudKitTests { #expect(URL(string: "file:memdb1?mode=memory&cache=shared")?.isInMemory == true) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func inMemoryUserDatabase() async throws { let syncEngine = try await SyncEngine( container: MockCloudContainer( @@ -39,6 +41,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test(.dependency(\.context, .live)) func inMemoryUserDatabase_LiveContext() async throws { let error = await #expect(throws: (any Error).self) { @@ -59,6 +62,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func metadatabaseMismatch() async throws { let error = await #expect(throws: (any Error).self) { var configuration = Configuration() diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 7a48a7f1..530d7e02 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -14,6 +14,7 @@ extension BaseCloudKitTests { @MainActor struct SyncEngineValidationTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tableNameValidation() async throws { let error = try #require( await #expect(throws: (any Error).self) { @@ -44,6 +45,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func foreignKeyActionValidation() async throws { let error = try #require( await #expect(throws: (any Error).self) { @@ -104,6 +106,7 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func userTriggerValidation() async throws { let error = try await #require( #expect(throws: (any Error).self) { @@ -164,12 +167,13 @@ extension BaseCloudKitTests { [1]: "temporary_trigger" ] ), - debugDescription: #"Triggers must include 'sqlitedata_icloud_syncEngineIsSynchronizingChanges()' check: '("non_temporary_trigger", "remindersLists", "CREATE TRIGGER \"non_temporary_trigger\"\nAFTER UPDATE ON \"remindersLists\"\nFOR EACH ROW BEGIN\n SELECT 1;\nEND")', '("temporary_trigger", "remindersLists", "CREATE TRIGGER \"temporary_trigger\"\nAFTER UPDATE ON \"remindersLists\"\nFOR EACH ROW BEGIN\n SELECT 1;\nEND")'."# + debugDescription: "Triggers must include \'SyncEngine.isSynchronizingChanges()\' (\'sqlitedata_icloud_syncEngineIsSynchronizingChanges()\') check: \'non_temporary_trigger\', \'temporary_trigger\'." ) """# } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func doNotValidateTriggersOnNonSyncedTables() async throws { let database = try DatabaseQueue( path: URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite").path() diff --git a/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift b/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift index 7a1ba9cd..a68b0ed6 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift @@ -3,6 +3,7 @@ import Testing import SharingGRDB @Suite struct UserlandTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func basics() async throws { let database = try SharingGRDBTests.database(containerIdentifier: "tests") let syncEngine = try SyncEngine( diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 195b2c9a..786fde92 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -14,12 +14,17 @@ import os } ) class BaseCloudKitTests: @unchecked Sendable { - let container: MockCloudContainer let userDatabase: UserDatabase private let _syncEngine: any Sendable + private let _container: any Sendable @Dependency(\.datetime.now) var now + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var container: MockCloudContainer { + _container as! MockCloudContainer + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var syncEngine: SyncEngine { _syncEngine as! SyncEngine @@ -39,12 +44,13 @@ class BaseCloudKitTests: @unchecked Sendable { try await setUpUserDatabase(userDatabase) let privateDatabase = MockCloudDatabase(databaseScope: .private) let sharedDatabase = MockCloudDatabase(databaseScope: .shared) - container = MockCloudContainer( + let container = MockCloudContainer( accountStatus: accountStatus, containerIdentifier: testContainerIdentifier, privateCloudDatabase: privateDatabase, sharedCloudDatabase: sharedDatabase ) + _container = container privateDatabase.set(container: container) sharedDatabase.set(container: container) _syncEngine = try await SyncEngine( @@ -81,6 +87,7 @@ class BaseCloudKitTests: @unchecked Sendable { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func signOut() async { container._accountStatus.withValue { $0 = .noAccount } await syncEngine.handleEvent( @@ -93,6 +100,7 @@ class BaseCloudKitTests: @unchecked Sendable { ) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func signIn() async { container._accountStatus.withValue { $0 = .available } await syncEngine.handleEvent( @@ -124,6 +132,7 @@ class BaseCloudKitTests: @unchecked Sendable { } } +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { var `private`: MockSyncEngine { syncEngines.private as! MockSyncEngine diff --git a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift index fa240a90..fe14d470 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift @@ -18,10 +18,12 @@ } } - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - extension CKRecord: @retroactive CustomDumpReflectable { + extension CKRecord { @TaskLocal static var printTimestamps = false + } + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKRecord: @retroactive CustomDumpReflectable { public var customDumpMirror: Mirror { let keys = encryptedValues.allKeys() .filter { key in diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift index bb90c86f..1f2d1835 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift @@ -5,6 +5,7 @@ import OrderedCollections import SharingGRDBCore import Testing +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConvertible { static func recordID( for id: PrimaryKey.QueryOutput, @@ -17,8 +18,7 @@ extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConver } } - - +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { struct ModifyRecordsCallback { fileprivate let operation: @Sendable () async -> Void diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SharingGRDBTests/Internal/Schema.swift index 2022a0e2..c79e4da8 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SharingGRDBTests/Internal/Schema.swift @@ -72,7 +72,7 @@ import SharingGRDB let id: Int } -@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func database(containerIdentifier: String) throws -> DatabasePool { var configuration = Configuration() configuration.prepareDatabase { db in diff --git a/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift b/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift index 4a1c7ea6..1f51b94b 100644 --- a/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift +++ b/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift @@ -2,8 +2,9 @@ import GRDB import SharingGRDBCore extension UserDatabase { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func userWrite( - _ updates: @escaping @Sendable (Database) throws -> T + _ updates: @Sendable (Database) throws -> T ) async throws -> T { try await write { db in try SyncEngine.$_isSynchronizingChanges.withValue(false) { @@ -12,8 +13,9 @@ extension UserDatabase { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func userRead( - _ updates: @escaping @Sendable (Database) throws -> T + _ updates: @Sendable (Database) throws -> T ) async throws -> T { try await read { db in try SyncEngine.$_isSynchronizingChanges.withValue(false) { @@ -23,6 +25,7 @@ extension UserDatabase { } @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func userWrite( _ updates: (Database) throws -> T ) throws -> T { @@ -34,6 +37,7 @@ extension UserDatabase { } @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func userRead( _ updates: (Database) throws -> T ) throws -> T { diff --git a/Tests/SharingGRDBTests/SharingGRDBTests.swift b/Tests/SharingGRDBTests/SharingGRDBTests.swift index 59bc0487..ffc088e5 100644 --- a/Tests/SharingGRDBTests/SharingGRDBTests.swift +++ b/Tests/SharingGRDBTests/SharingGRDBTests.swift @@ -76,6 +76,7 @@ import Testing } @Test(.dependency(\.defaultDatabase, try .database())) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func fetchAnimationHashValue() async throws { let fetchKey1: some SharedReaderKey = .fetch(Fetch1()) let fetchKey2: some SharedReaderKey = .fetch(Fetch2(), animation: .default) From e95e09d26b2cad092741d1100b5e1a949af68f1a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 1 Sep 2025 14:11:09 -0700 Subject: [PATCH 497/581] Minimize exports --- Sources/SharingGRDBCore/Internal/Exports.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/Internal/Exports.swift b/Sources/SharingGRDBCore/Internal/Exports.swift index 8768304b..591037e3 100644 --- a/Sources/SharingGRDBCore/Internal/Exports.swift +++ b/Sources/SharingGRDBCore/Internal/Exports.swift @@ -1,4 +1,12 @@ @_exported import Dependencies -@_exported import GRDB @_exported import Sharing @_exported import StructuredQueriesGRDBCore + +@_exported import class GRDB.Database +@_exported import class GRDB.DatabasePool +@_exported import class GRDB.DatabaseQueue +@_exported import protocol GRDB.DatabaseReader +@_exported import protocol GRDB.DatabaseWriter +@_exported import protocol GRDB.ValueObservationScheduler +@_exported import struct GRDB.Configuration +@_exported import struct GRDB.DatabaseMigrator From b27266c6fd3ddc8091cefea2c1441d85b9e8bfae Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 1 Sep 2025 14:14:35 -0700 Subject: [PATCH 498/581] wip --- Examples/Reminders/Schema.swift | 2 +- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index cf8b4f7e..ec79f5be 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -90,7 +90,7 @@ struct ReminderTag: Hashable, Identifiable { } @Table @Selection -struct ReminderText: StructuredQueries.FTS5 { +struct ReminderText: FTS5 { let rowid: Int let title: String let notes: String diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ad271a90..3a1730ae 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -3,6 +3,7 @@ import ConcurrencyExtras import CustomDump import Dependencies + import GRDB import OrderedCollections import OSLog import StructuredQueriesCore From 542dd28f7de773f7fcb5c58025f835395dee9791 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 1 Sep 2025 15:58:20 -0700 Subject: [PATCH 499/581] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 3a1730ae..9233a824 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1561,7 +1561,7 @@ } @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - extension DatabaseFunction { + extension GRDB.DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { Self("didUpdate") { recordName, zoneID, _ in syncEngine.didUpdate( From d407c68874f3fb8c972f1c65a8d77b20375dc7c8 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 1 Sep 2025 16:08:01 -0700 Subject: [PATCH 500/581] Remove trigger check --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 19 ------ .../SyncEngineValidationTests.swift | 67 ------------------- 2 files changed, 86 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 9233a824..ae4b0c1a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1798,7 +1798,6 @@ case metadatabaseMismatch(attachedPath: String, syncEngineConfiguredPath: String) case noCloudKitContainer case nonNullColumnsWithoutDefault(tableName: String, columnNames: [String]) - case triggersWithoutSynchronizationCheck([String]) case unknown } let reason: Reason @@ -1862,24 +1861,6 @@ ) .fetchAll(db) .filter { _, tableName, _ in tableNames.contains(tableName) } - let invalidTriggers = triggers.compactMap { name, _, sql in - let isValid = - sql - .lowercased() - .contains("\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()".lowercased()) - return isValid ? nil : name - } - guard invalidTriggers.isEmpty - else { - throw SyncEngine.SchemaError( - reason: .triggersWithoutSynchronizationCheck(invalidTriggers), - debugDescription: """ - Triggers must include 'SyncEngine.isSynchronizingChanges()' \ - ('\(DatabaseFunction.syncEngineIsSynchronizingChanges.name)()') \ - check: \(invalidTriggers.map { "'\($0)'" }.joined(separator: ", ")). - """ - ) - } for (tableName, foreignKeys) in foreignKeysByTableName { if foreignKeys.count == 1, diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 530d7e02..3ae73cbf 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -106,73 +106,6 @@ extension BaseCloudKitTests { } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func userTriggerValidation() async throws { - let error = try await #require( - #expect(throws: (any Error).self) { - let database = try DatabaseQueue() - try await database.write { db in - try #sql( - """ - CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' - ) STRICT - """ - ) - .execute(db) - try #sql( - """ - CREATE TRIGGER "non_temporary_trigger" - AFTER UPDATE ON "remindersLists" - FOR EACH ROW BEGIN - SELECT 1; - END - """ - ) - .execute(db) - try #sql( - """ - CREATE TEMPORARY TRIGGER "temporary_trigger" - AFTER UPDATE ON "remindersLists" - FOR EACH ROW BEGIN - SELECT 1; - END - """ - ) - .execute(db) - } - let _ = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "deadbeef", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ), - userDatabase: UserDatabase(database: database), - tables: [RemindersList.self] - ) - } - ) - assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { - """ - "Could not synchronize data with iCloud." - """ - } - assertInlineSnapshot(of: error, as: .customDump) { - #""" - SyncEngine.SchemaError( - reason: .triggersWithoutSynchronizationCheck( - [ - [0]: "non_temporary_trigger", - [1]: "temporary_trigger" - ] - ), - debugDescription: "Triggers must include \'SyncEngine.isSynchronizingChanges()\' (\'sqlitedata_icloud_syncEngineIsSynchronizingChanges()\') check: \'non_temporary_trigger\', \'temporary_trigger\'." - ) - """# - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func doNotValidateTriggersOnNonSyncedTables() async throws { let database = try DatabaseQueue( From 4ca296d1276d31d5a16cffd6c4121f3578d796a5 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:12:46 -0500 Subject: [PATCH 501/581] Some permission fixes (#147) * Fix up some permission loopholes. * wip * wip * clean up --- .../CloudKit/Internal/MockCloudDatabase.swift | 35 +- .../CloudKit/Internal/MockSyncEngine.swift | 2 +- .../CloudKit/Metadatabase.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 33 +- .../SharingPermissionsTests.swift | 469 ++++++++++++++++++ .../CloudKitTests/SharingTests.swift | 266 ---------- .../SyncEngineLifecycleTests.swift | 7 + 7 files changed, 539 insertions(+), 275 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift index 7bf55819..74388699 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -88,7 +88,9 @@ package final class MockCloudDatabase: CloudDatabase { case .ifServerRecordUnchanged: for recordToSave in recordsToSave { if let share = recordToSave as? CKShare { - let isSavingRootRecord = recordsToSave.contains(where: { $0.share?.recordID == share.recordID }) + let isSavingRootRecord = recordsToSave.contains(where: { + $0.share?.recordID == share.recordID + }) let shareWasPreviouslySaved = storage[share.recordID.zoneID]?[share.recordID] != nil guard shareWasPreviouslySaved || isSavingRootRecord else { @@ -102,7 +104,7 @@ package final class MockCloudDatabase: CloudDatabase { continue } } - + guard storage[recordToSave.recordID.zoneID] != nil else { saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) @@ -124,6 +126,33 @@ package final class MockCloudDatabase: CloudDatabase { return } + func root(of record: CKRecord) -> CKRecord { + guard let parent = record.parent + else { return record } + return (storage[parent.recordID.zoneID]?[parent.recordID]).map(root) ?? record + } + func share(for rootRecord: CKRecord) -> CKShare? { + for (_, record) in storage[rootRecord.recordID.zoneID] ?? [:] { + guard record.recordID == rootRecord.share?.recordID + else { continue } + return record as? CKShare + } + return nil + } + let rootRecord = root(of: recordToSave) + let share = share(for: rootRecord) + let isSavingShare = recordsToSave.contains { $0.recordID == share?.recordID } + if + !isSavingShare, + !(recordToSave is CKShare), + let share, + !(share.publicPermission == .readWrite + || share.currentUserParticipant?.permission == .readWrite) + { + saveResults[recordToSave.recordID] = .failure(CKError(.permissionFailure)) + return + } + guard let copy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } copy._recordChangeTag = UUID().uuidString @@ -137,7 +166,7 @@ package final class MockCloudDatabase: CloudDatabase { } } - // TODO: this should merge copy's values into storage but not sure how right now. + // TODO: this should merge copy's values into storage but not sure how right now. storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy saveResults[recordToSave.recordID] = .success(copy) } diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift index 07bee97f..82d4819d 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift @@ -79,7 +79,7 @@ package final class MockSyncEngine: SyncEngineProtocol { } } - state.remove(pendingRecordZoneChanges: recordIDsSkipped.map { .saveRecord($0) }) + state.remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) }) return CKSyncEngine.RecordZoneChangeBatch( recordsToSave: recordsToSave, diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index e728a8bf..62c617b7 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -116,7 +116,7 @@ func defaultMetadatabase( ) .execute(db) } - migrator.registerMigration("Create PendingRecodZoneChanges Table") { db in + migrator.registerMigration("Create PendingRecordZoneChanges Table") { db in try SQLQueryExpression(""" CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( "pendingRecordZoneChange" BLOB NOT NULL diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ae4b0c1a..4700c641 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -357,7 +357,7 @@ .select(\.pendingRecordZoneChange) .fetchAll(db) } - let changesByIsPrivate = Dictionary.init(grouping: pendingRecordZoneChanges) { + let changesByIsPrivate = Dictionary(grouping: pendingRecordZoneChanges) { switch $0 { case .deleteRecord(let recordID), .saveRecord(let recordID): recordID.zoneID.ownerName == CKCurrentUserDefaultName @@ -1266,10 +1266,32 @@ try open(table) } + case .permissionFailure: + guard + let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey, + let table = tablesByName[failedRecord.recordType] + else { continue } + func open(_: T.Type) async throws { + do { + let serverRecord = try await container.sharedCloudDatabase.record(for: failedRecord.recordID) + upsertFromServerRecord(serverRecord, force: true) + } catch let error as CKError where error.code == .unknownItem { + try await userDatabase.write { db in + try T + .where { SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") } + .delete() + .execute(db) + } + } + } + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await open(table) + } + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled, .batchRequestFailed, .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, - .permissionFailure, .invalidArguments, .resultsTruncated, .assetFileNotFound, + .invalidArguments, .resultsTruncated, .assetFileNotFound, .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired, .badDatabase, .quotaExceeded, .limitExceeded, .userDeletedZone, .tooManyParticipants, .alreadyShared, .managedAccountRestricted, .participantMayNeedVerification, @@ -1339,7 +1361,10 @@ } } - private func upsertFromServerRecord(_ serverRecord: CKRecord) { + private func upsertFromServerRecord( + _ serverRecord: CKRecord, + force: Bool = false + ) { withErrorReporting(.sqliteDataCloudKitFailure) { guard let table = tablesByName[serverRecord.recordType] else { @@ -1377,7 +1402,7 @@ func open(_: T.Type) throws { var columnNames = T.TableColumns.writableColumns.map(\.name) - if let metadata, let allFields = metadata._lastKnownServerRecordAllFields { + if !force, let metadata, let allFields = metadata._lastKnownServerRecordAllFields { let row = try userDatabase.read { db in try T.find(SQLQueryExpression("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift new file mode 100644 index 00000000..3207d88b --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift @@ -0,0 +1,469 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import OrderedCollections +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class SharingPermissionsTests: BaseCloudKitTests, @unchecked Sendable { + /// Inserting record into shared record when user does not have permission should be rejected. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertRecordInReadOnlyRemindersList() 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 + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.all.fetchCount(db) == 0) + } + 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" + ) + ] + ) + ) + """ + } + } + + /// Delete record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteReminderInReadOnlyRemindersList() 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 reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + + _ = try syncEngine.modifyRecords( + scope: .shared, + saving: [reminderRecord, remindersListRecord] + ) + + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + + let share = CKShare( + rootRecord: freshRemindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(freshRemindersListRecord.recordID.recordName)", + zoneID: freshRemindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: share + ) + ) + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.find(1).delete().execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.count().fetchOne(db) == 1) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + 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" + ) + ] + ) + ) + """ + } + } + + /// Editing record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editReminderInReadOnlyRemindersList() 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 reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.setValue(false, forKey: "isCompleted", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + _ = try syncEngine.modifyRecords( + scope: .shared, + saving: [remindersListRecord, reminderRecord] + ) + + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + let share = CKShare( + rootRecord: freshRemindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(freshRemindersListRecord.recordID.recordName)", + zoneID: freshRemindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: share + ) + ) + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.update { $0.isCompleted = true }.execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.where(\.isCompleted).fetchCount(db) == 0) + } + 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" + ) + ] + ) + ) + """ + } + } + + // Edit a record while locally we think we have permission, but CloudKit has newer permissions + // that are read only. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createRecordWhenLocalHasPermissionsButCloudKitDoesNot() 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 + ) + ) + share.publicPermission = .readWrite + share.currentUserParticipant?.permission = .readWrite + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + freshShare.publicPermission = .readOnly + freshShare.currentUserParticipant?.permission = .readOnly + let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await self.userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await self.userDatabase.userRead { db in + try #expect(Reminder.all.fetchCount(db) == 0) + } + 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" + ) + ] + ) + ) + """ + } + } + + + // Edit a record while locally we think we have permission, but CloudKit has newer permissions + // that are read only. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editRecordWhenLocalHasPermissionsButCloudKitDoesNot() 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 + ) + ) + share.publicPermission = .readWrite + share.currentUserParticipant?.permission = .readWrite + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + freshShare.publicPermission = .readOnly + freshShare.currentUserParticipant?.permission = .readOnly + let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await self.userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Business" }.execute(db) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await self.userDatabase.userRead { db in + try #expect(RemindersList.find(1).fetchOne(db) == RemindersList(id: 1, title: "Personal")) + } + 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" + ) + ] + ) + ) + """ + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 58c7e2a1..671de0fa 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -897,272 +897,6 @@ extension BaseCloudKitTests { """ } } - - /// Inserting record into shared record when user does not have permission should be rejected. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func insertRecordInReadOnlyRemindersList() 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 - ) - ) - share.publicPermission = .readOnly - share.currentUserParticipant?.permission = .readOnly - - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share - ) - ) - - - try await self.userDatabase.userWrite { db in - let error = #expect(throws: DatabaseError.self) { - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } - } - #expect(error?.message == SyncEngine.writePermissionError) - try #expect(Reminder.all.fetchCount(db) == 0) - } - 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" - ) - ] - ) - ) - """ - } - } - - /// Delete record in shared record when user does not have permission. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteReminderInReadOnlyRemindersList() 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 - ) - ) - share.publicPermission = .readOnly - share.currentUserParticipant?.permission = .readOnly - - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share - ) - ) - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - try await syncEngine.modifyRecords(scope: .shared, saving: [reminderRecord]).notify() - - try await self.userDatabase.userWrite { db in - let error = #expect(throws: DatabaseError.self) { - try Reminder.find(1).delete().execute(db) - } - #expect(error?.message == SyncEngine.writePermissionError) - try #expect(Reminder.count().fetchOne(db) == 1) - } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:reminders/external.zone/external.owner), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), - share: nil, - id: 1, - 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" - ) - ] - ) - ) - """ - } - } - - /// Editing record in shared record when user does not have permission. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func editReminderInReadOnlyRemindersList() 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 - ) - ) - share.publicPermission = .readOnly - share.currentUserParticipant?.permission = .readOnly - - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share - ) - ) - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.setValue(false, forKey: "isCompleted", at: now) - reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - try await syncEngine.modifyRecords(scope: .shared, saving: [reminderRecord]).notify() - - try await self.userDatabase.userWrite { db in - let error = #expect(throws: DatabaseError.self) { - try Reminder.update { $0.isCompleted = true }.execute(db) - } - #expect(error?.message == SyncEngine.writePermissionError) - try #expect(Reminder.where(\.isCompleted).fetchCount(db) == 0) - } - 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" - ) - ] - ) - ) - """ - } - } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift index 1d757710..769d8c2c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -26,6 +26,8 @@ extension BaseCloudKitTests { } } + 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) @@ -103,6 +105,8 @@ extension BaseCloudKitTests { try RemindersList.find(1).delete().execute(db) } + try await Task.sleep(for: .seconds(0.5)) + try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -139,6 +143,7 @@ extension BaseCloudKitTests { 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 userDatabase.read { db in try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) @@ -209,6 +214,7 @@ extension BaseCloudKitTests { } } + try await Task.sleep(for: .seconds(0.5)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .shared) @@ -337,6 +343,7 @@ extension BaseCloudKitTests { try RemindersList.find(1).delete().execute(db) } + try await Task.sleep(for: .seconds(0.5)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .private) From 680e3ee1e5be05d4272e2aafbd125186bb3ec490 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:12:53 -0500 Subject: [PATCH 502/581] Document scene delegate methods for accepting shares. (#148) * Document scene delegate methods for accepting shares. * Update Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md --------- Co-authored-by: Stephen Celis --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 17 +++++++++++++- .../CloudKitPlaygroundApp.swift | 21 +++++++++++++++-- Examples/Reminders/RemindersApp.swift | 17 +++++++++++++- .../Articles/CloudKitSharing.md | 23 +++++++++++++++---- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index e96f652b..6fb3c017 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -52,8 +52,9 @@ struct CloudKitDemoApp: App { } class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? @Dependency(\.defaultSyncEngine) var syncEngine + var window: UIWindow? + func windowScene( _ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata @@ -62,5 +63,19 @@ struct CloudKitDemoApp: App { try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) } } + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let cloudKitShareMetadata = connectionOptions.cloudKitShareMetadata + else { + return + } + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } } #endif diff --git a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift index 44c9512f..b9010b25 100644 --- a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift +++ b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift @@ -12,7 +12,9 @@ struct CloudKitPlaygroundApp: App { $0.defaultDatabase = try! appDatabase() $0.defaultSyncEngine = try! SyncEngine( for: $0.defaultDatabase, - tables: ModelA.self, ModelB.self, ModelC.self + tables: ModelA.self, + ModelB.self, + ModelC.self ) } } @@ -48,12 +50,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } class SceneDelegate: UIResponder, UIWindowSceneDelegate { + @Dependency(\.defaultSyncEngine) var syncEngine var window: UIWindow? + func windowScene( _ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata ) { - @Dependency(\.defaultSyncEngine) var syncEngine + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let cloudKitShareMetadata = connectionOptions.cloudKitShareMetadata + else { + return + } Task { try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) } diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 6640a5cd..14cd2e38 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -55,12 +55,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { } class SceneDelegate: UIResponder, UIWindowSceneDelegate { + @Dependency(\.defaultSyncEngine) var syncEngine var window: UIWindow? + func windowScene( _ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata ) { - @Dependency(\.defaultSyncEngine) var syncEngine + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let cloudKitShareMetadata = connectionOptions.cloudKitShareMetadata + else { + return + } Task { try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md index a492d29e..c9b68901 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md @@ -63,10 +63,10 @@ configure how they want to share the record. A record can be _unshared_ by prese ## Accepting shared records Extra steps must be taken to allow a user to _accept_ a shared record. Once the user taps on the -share link sent to them (whether that is by text, email, etc.), the app will be launched and a -special `userDidAcceptCloudKitShareWith` delegate method will be invoked in the app's scene -delegate. Your app must implement this delegate method and invoke the -``SyncEngine/acceptShare(metadata:)`` method. +share link sent to them (whether that is by text, email, etc.), the app will be launched with +special options provided or a special delegate method will be invoked in the app's scene delegate. +You must implement these delegate methods and invoke the ``SyncEngine/acceptShare(metadata:)`` +method. As a simplified example, a `UIWindowSceneDelegate` subclass can implement the delegate method like so: @@ -75,6 +75,7 @@ so: class SceneDelegate: UIResponder, UIWindowSceneDelegate { @Dependency(\.defaultSyncEngine) var syncEngine var window: UIWindow? + func windowScene( _ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata @@ -83,6 +84,20 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) } } + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let cloudKitShareMetadata = connectionOptions.cloudKitShareMetadata + else { + return + } + Task { + try await syncEngine.acceptShare(metadata: cloudKitShareMetadata) + } + } } ``` From 61130872df5eacb62f535a577096e4378a5c5ac7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 2 Sep 2025 12:15:50 -0500 Subject: [PATCH 503/581] Clean up some tests --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 15 ----- .../ForeignKeyConstraintTests.swift | 30 +++++---- .../ReferenceViolationTests.swift | 61 +++++++++--------- .../CloudKitTests/SharingTests.swift | 1 + .../SyncEngineValidationTests.swift | 63 ++++++++++++++++++- 5 files changed, 105 insertions(+), 65 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4700c641..feac3959 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -1872,21 +1872,6 @@ } } try userDatabase.read { db in - let triggers = try SQLQueryExpression( - """ - SELECT "name", "tbl_name", "sql" - FROM "sqlite_master" - WHERE "type" = 'trigger' - UNION - SELECT "name", "tbl_name", "sql" - FROM "sqlite_temp_master" - WHERE "type" = 'trigger' - """, - as: (String, String, String).self - ) - .fetchAll(db) - .filter { _, tableName, _ in tableNames.contains(tableName) } - for (tableName, foreignKeys) in foreignKeysByTableName { if foreignKeys.count == 1, let foreignKey = foreignKeys.first, diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index ca260633..869f1176 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -26,7 +26,7 @@ extension BaseCloudKitTests { reminderRecord.setValue("Get milk", forKey: "title", at: now) reminderRecord.setValue(1, forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 1), + record: remindersListRecord, action: .none ) @@ -159,10 +159,10 @@ extension BaseCloudKitTests { let modelARecord = CKRecord(recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1)) let modelBRecord = CKRecord(recordType: ModelB.tableName, recordID: ModelB.recordID(for: 1)) modelBRecord.setValue(1, forKey: "modelAID", at: now) - modelBRecord.parent = CKRecord.Reference(recordID: modelARecord.recordID, action: .none) + modelBRecord.parent = CKRecord.Reference(record: modelARecord, action: .none) let modelCRecord = CKRecord(recordType: ModelC.tableName, recordID: ModelC.recordID(for: 1)) modelCRecord.setValue(1, forKey: "modelBID", at: now) - modelCRecord.parent = CKRecord.Reference(recordID: modelBRecord.recordID, action: .none) + modelCRecord.parent = CKRecord.Reference(record: modelBRecord, action: .none) try await syncEngine.modifyRecords(scope: .private, saving: [modelARecord]).notify() _ = try syncEngine.modifyRecords(scope: .private, saving: [modelBRecord]) @@ -209,13 +209,11 @@ extension BaseCloudKitTests { } } + // * Receive child record with no parent record. + // * Receive parent record. + // => Both records are synchronized. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test( - """ - 1) Receive child record without parent. - 2) Receive child record with parent - """ - ) func receiveChildRecordBeforeParent_ReceiveChildAndParentRecord() async throws { + @Test func receiveChildRecordBeforeParent_ReceiveChildAndParentRecord() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) @@ -231,7 +229,7 @@ extension BaseCloudKitTests { reminderRecord.setValue("Get milk", forKey: "title", at: now) reminderRecord.setValue(1, forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 1), + record: remindersListRecord, action: .none ) @@ -441,12 +439,12 @@ extension BaseCloudKitTests { } } + // * Remote moves child to a parent the local client does not know about. + // * Remote syncs child to local. + // * Remote syncs parent to local. + // => Parent and child records are synchronized. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test( - """ - Remote changes parent relationship to an unknown record which is synchronized later. - """ - ) + @Test func changeParentRelationshipToUnknownRecord() async throws { let personalListRecord = CKRecord( recordType: RemindersList.tableName, @@ -470,7 +468,7 @@ extension BaseCloudKitTests { reminderRecord.setValue("Get milk", forKey: "title", at: now) reminderRecord.setValue(1, forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 1), + record: personalListRecord, action: .none ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index 23770546..6c2bd323 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -9,13 +9,11 @@ import Testing extension BaseCloudKitTests { @MainActor final class ReferenceViolationTests: BaseCloudKitTests, @unchecked Sendable { + // * Local client moves a reminder to a list. + // * At same time, remote deletes that list. + // => When data is synchronized the reminder and list are deleted. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test( - """ - * The local client moves a reminder to a list. - * The remote client deletes that list. - """ - ) func moveReminderToList_RemoteDeletesList() async throws { + @Test func moveReminderToList_RemoteDeletesList() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") @@ -41,6 +39,10 @@ extension BaseCloudKitTests { await modifications.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await userDatabase.read { db in + try #expect(Reminder.find(1).fetchCount(db) == 0) + try #expect(RemindersList.find(2).fetchCount(db) == 0) + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -75,13 +77,12 @@ extension BaseCloudKitTests { } } + // * Local client deletes a list + // * At the same time, remote adds a reminder to that list. + // * Local data is sync'd first, then remote data syncs. + // => Deletion is rejected and the list and reminder are sync'd to local client. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test( - """ - * The local client deletes a list. - * The remote client adds reminder to that list. - """ - ) func deleteList_RemoteAddsReminderToList() async throws { + @Test func deleteList_RemoteAddsReminderToList() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") @@ -158,14 +159,12 @@ extension BaseCloudKitTests { } } + // * Local client deletes a list + // * At the same time, remote adds a reminder to that list. + // * Remote data is sync'd first, then local data syncs. + // => Deletion is rejected and the list and reminder are sync'd to local client. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test( - """ - * The local client deletes a list. - * The remote client adds reminder to that list. - * Remote syncs to local client before local sends batch. - """ - ) func deleteList_RemoteAddsReminderToList_Variation() async throws { + @Test func deleteList_RemoteAddsReminderToList_Variation() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") @@ -242,14 +241,12 @@ extension BaseCloudKitTests { } } + // * Local client move child to parent. + // * Remote client deletes parent. + // * Local data is sync'd first, then remote data syncs. + // => Local client sets parent relationship to NULL and parent is deleted. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test( - """ - * The local client moves child to parent. - * The remote client deletes parent. - * Local client sets parent relationship to NULL. - """ - ) func moveChildToParent_RemoteDeletesParent_CascadeSetNull() async throws { + @Test func moveChildToParent_RemoteDeletesParent_CascadeSetNull() async throws { try await userDatabase.userWrite { db in try db.seed { Parent(id: 1) @@ -321,14 +318,12 @@ extension BaseCloudKitTests { } } + // * Local client move child to parent. + // * Remote client deletes parent. + // * Local data is sync'd first, then remote data syncs. + // => Local client sets parent relationship to default value. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test( - """ - * The local client moves child to parent. - * The remote client deletes parent. - * Local client sets parent relationship to default value. - """ - ) func moveChildToParent_RemoteDeletesParent_CascadeSetDefault() async throws { + @Test func moveChildToParent_RemoteDeletesParent_CascadeSetDefault() async throws { try await userDatabase.userWrite { db in try db.seed { Parent(id: 0) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 671de0fa..2b66ceaf 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -1,5 +1,6 @@ import CloudKit import CustomDump +import GRDB import Foundation import InlineSnapshotTesting import OrderedCollections diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 3ae73cbf..d0905fdb 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -46,7 +46,7 @@ extension BaseCloudKitTests { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func foreignKeyActionValidation() async throws { + @Test func foreignKeyActionValidation_NoAction() async throws { let error = try #require( await #expect(throws: (any Error).self) { var configuration = Configuration() @@ -106,6 +106,67 @@ extension BaseCloudKitTests { } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeyActionValidation_Restrict() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + var configuration = Configuration() + configuration.foreignKeysEnabled = false + let database = try DatabaseQueue(configuration: configuration) + try await database.write { db in + try #sql( + """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "children" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE RESTRICT + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .invalidForeignKeyAction( + ForeignKey( + table: "parents", + from: "parentID", + to: "id", + onUpdate: .noAction, + onDelete: .restrict, + notnull: false + ) + ), + debugDescription: #"Foreign key "children"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# + ) + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func doNotValidateTriggersOnNonSyncedTables() async throws { let database = try DatabaseQueue( From 5bfe610ebb06d40523734a97c276dd5ede3b8a0d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 2 Sep 2025 12:20:20 -0500 Subject: [PATCH 504/581] Add link to apple docs for deploying schema --- .../SharingGRDBCore/Documentation.docc/Articles/CloudKit.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 51043dc9..77044165 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -48,10 +48,13 @@ The steps to set up your SharingGRDB project for CloudKit synchronization are th * If you want to enable sharing of records with other iCloud users, be sure to add a `CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented in [Apple's documentation for sharing]. + * Once you are ready to deploy your app be sure to read Apple's documentation on + [Deploying an iCloud Container’s Schema]. With those steps completed, you are ready to configure a ``SyncEngine`` that will facilitate synchronizing your database to and from CloudKit. +[Deploying an iCloud Container’s Schema]: https://developer.apple.com/documentation/CloudKit/deploying-an-icloud-container-s-schema [Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic [setup-cloudkit-apple]: https://developer.apple.com/documentation/swiftdata/syncing-model-data-across-a-persons-devices#Add-the-iCloud-and-Background-Modes-capabilities [Configuring iCloud services]: https://developer.apple.com/documentation/Xcode/configuring-icloud-services From 4ff43383ab9fcae27f4c7dc73e435cb3b4758fc5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 2 Sep 2025 14:25:55 -0500 Subject: [PATCH 505/581] update docs wrt SyncEngine.isSychnronizing --- .../Documentation.docc/Articles/CloudKit.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 77044165..98d0c80e 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -581,10 +581,14 @@ If you have triggers installed on your tables, then you may want to customize th to behave differently depending on whether a write is happening to your database from your own code or from the sync engine. For example, if you have a trigger that refreshes an `updatedAt` timestamp on a row when it is edited, it would not be appropriate to do that when the sync engine -updates a row from data received from CloudKit. +updates a row from data received from CloudKit. But, if you have a trigger that updates a local +[FTS] index, then you would want to perform that work regardless if your app is updating the data +or CloudKit is updating the data. -To prevent this you can use the ``SyncEngine/isSynchronizingChanges()`` SQL expression. It -represents a custom database function that is installed in your database connection, and it will +[FTS]: https://sqlite.org/fts5.html + +To customize this behavior you can use the ``SyncEngine/isSynchronizingChanges()`` SQL expression. +It represents a custom database function that is installed in your database connection, and it will return true if the write to your database originates from the sync engine. You can use it in a trigger like so: From df0ef93ea86c94afbad4b2d72b8ce18eaeaf10c4 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:01:26 -0500 Subject: [PATCH 506/581] Delete CK record when updating primary key. (#151) * Delete CK record when updating primary key. * wip * wip --- .../SharingGRDBCore/CloudKit/Triggers.swift | 19 ++ .../Internal/UserDatabase.swift | 2 +- .../CloudKitTests/AccountLifecycleTests.swift | 10 +- .../CloudKitTests/AssetsTests.swift | 12 +- .../CloudKitTests/CloudKitTests.swift | 28 +- .../FetchRecordZoneChangesTests.swift | 64 ++-- .../FetchedDatabaseChangesTests.swift | 20 +- .../ForeignKeyConstraintTests.swift | 64 ++-- .../CloudKitTests/MergeConflictTests.swift | 66 ++--- .../CloudKitTests/MetadataTests.swift | 26 +- .../MockCloudDatabaseTests.swift | 10 +- .../CloudKitTests/NewTableSyncTests.swift | 18 +- .../NextRecordZoneChangeBatchTests.swift | 14 +- .../ReferenceViolationTests.swift | 78 ++--- .../SharingPermissionsTests.swift | 1 + .../CloudKitTests/SharingTests.swift | 16 +- .../SyncEngineLifecycleTests.swift | 20 +- .../CloudKitTests/TriggerTests.swift | 274 +++++++++++++++++- .../Internal/BaseCloudKitTests.swift | 2 +- 19 files changed, 510 insertions(+), 234 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 099275d4..04a907a9 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -10,9 +10,28 @@ afterUpdate(parentForeignKey: parentForeignKey), afterDeleteFromUser(parentForeignKey: parentForeignKey), afterDeleteFromSyncEngine, + afterPrimaryKeyChange(parentForeignKey: parentForeignKey), ] } + fileprivate static func afterPrimaryKeyChange(parentForeignKey: ForeignKey?) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_primary_key_change_on_\(tableName)", + ifNotExists: true, + after: .update(of: \.primaryKey) { old, new in + checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) + SyncMetadata + .where { + $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) + && $0.recordType.eq(tableName) + } + .update { $0._isDeleted = true } + } when: { old, new in + old.primaryKey.neq(new.primaryKey) + } + ) + } + fileprivate static func afterInsert(parentForeignKey: ForeignKey?) -> TemporaryTrigger { createTemporaryTrigger( "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", diff --git a/Sources/SharingGRDBCore/Internal/UserDatabase.swift b/Sources/SharingGRDBCore/Internal/UserDatabase.swift index 29a448be..95299501 100644 --- a/Sources/SharingGRDBCore/Internal/UserDatabase.swift +++ b/Sources/SharingGRDBCore/Internal/UserDatabase.swift @@ -2,7 +2,7 @@ import Dependencies import GRDB package struct UserDatabase { - private let database: any DatabaseWriter + package let database: any DatabaseWriter package init(database: any DatabaseWriter) { self.database = database } diff --git a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift index 9efd74c3..1819a06e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift @@ -67,9 +67,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -77,16 +77,16 @@ extension BaseCloudKitTests { title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersListPrivates/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, position: 0, remindersListID: 1 ), [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift index 41fefae0..31921095 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift @@ -33,9 +33,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersListAssets/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), recordType: "remindersListAssets", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, remindersListID: 1, @@ -45,7 +45,7 @@ extension BaseCloudKitTests { ) ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -89,9 +89,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersListAssets/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), recordType: "remindersListAssets", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, remindersListID: 1, @@ -101,7 +101,7 @@ extension BaseCloudKitTests { ) ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift index aca71edd..d93c3386 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift @@ -468,7 +468,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -515,7 +515,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -597,7 +597,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -632,7 +632,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -687,7 +687,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -738,7 +738,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -783,7 +783,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -816,7 +816,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -866,7 +866,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -899,7 +899,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -987,14 +987,14 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(fun:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(fun:tags/zone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, title: "fun" ), [1]: CKRecord( - recordID: CKRecord.ID(weekend:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, @@ -1028,7 +1028,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:modelAs/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), recordType: "modelAs", parent: nil, share: nil, @@ -1057,7 +1057,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:modelAs/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), recordType: "modelAs", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift index b6030b58..509222d2 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -4,6 +4,7 @@ import Foundation import InlineSnapshotTesting import OrderedCollections import SharingGRDB +import SharingGRDBTestSupport import SnapshotTestingCustomDump import Testing @@ -38,9 +39,9 @@ extension BaseCloudKitTests { """ [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -49,7 +50,7 @@ extension BaseCloudKitTests { title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -81,9 +82,9 @@ extension BaseCloudKitTests { """ [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 1, @@ -92,7 +93,7 @@ extension BaseCloudKitTests { title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -138,9 +139,9 @@ extension BaseCloudKitTests { ) { """ CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -173,9 +174,9 @@ extension BaseCloudKitTests { ) { """ CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -221,7 +222,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -264,7 +265,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -321,16 +322,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + 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/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -386,9 +387,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -396,7 +397,7 @@ extension BaseCloudKitTests { title: "Buy milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -480,6 +481,16 @@ extension BaseCloudKitTests { } try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(SyncMetadata.select(\.recordName), database: userDatabase.database) { + """ + ┌────────────────────┐ + │ "1:remindersLists" │ + │ "1:reminders" │ + │ "1:reminderTags" │ + │ "optional:tags" │ + └────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -487,7 +498,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminderTags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), recordType: "reminderTags", parent: nil, share: nil, @@ -496,9 +507,9 @@ extension BaseCloudKitTests { tagID: "optional" ), [1]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -506,7 +517,7 @@ extension BaseCloudKitTests { title: "Get milk" ), [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -514,18 +525,11 @@ extension BaseCloudKitTests { title: "Personal" ), [3]: CKRecord( - recordID: CKRecord.ID(optional:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(optional:tags/zone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, title: "optional" - ), - [4]: CKRecord( - recordID: CKRecord.ID(weekend:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "tags", - parent: nil, - share: nil, - title: "weekend" ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift b/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift index 673f6050..3d6c046d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift @@ -83,9 +83,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -93,9 +93,9 @@ extension BaseCloudKitTests { title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(2:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(2:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), share: nil, id: 2, isCompleted: 0, @@ -103,25 +103,25 @@ extension BaseCloudKitTests { title: "Call accountant" ), [2]: CKRecord( - recordID: CKRecord.ID(1:remindersListPrivates/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, position: 0, remindersListID: 1 ), [3]: CKRecord( - recordID: CKRecord.ID(2:remindersListPrivates/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(2:remindersListPrivates/zone/__defaultOwner__), recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), share: nil, id: 2, position: 0, remindersListID: 2 ), [4]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -129,7 +129,7 @@ extension BaseCloudKitTests { title: "Personal" ), [5]: CKRecord( - recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index 869f1176..cf72e213 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -43,16 +43,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + 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/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -115,9 +115,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -125,7 +125,7 @@ extension BaseCloudKitTests { title: "Buy milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -193,7 +193,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:modelAs/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), recordType: "modelAs", parent: nil, share: nil @@ -254,16 +254,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + 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/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -325,16 +325,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + 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/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -406,9 +406,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -416,7 +416,7 @@ extension BaseCloudKitTests { title: "Buy milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -484,16 +484,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + 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/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -536,16 +536,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, remindersListID: 2, title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -553,7 +553,7 @@ extension BaseCloudKitTests { title: "Personal" ), [2]: CKRecord( - recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -641,9 +641,9 @@ extension BaseCloudKitTests { ) { """ CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -720,9 +720,9 @@ extension BaseCloudKitTests { ) { """ CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -742,9 +742,9 @@ extension BaseCloudKitTests { ) { """ CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -792,9 +792,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(3:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(3:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__)), share: nil, id: 3, isCompleted: 0, @@ -802,7 +802,7 @@ extension BaseCloudKitTests { title: "Schedule secret meeting" ), [1]: CKRecord( - recordID: CKRecord.ID(3:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift index 9dcb334a..3663248d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift @@ -26,9 +26,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -41,7 +41,7 @@ extension BaseCloudKitTests { 🗓️: 0 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -84,9 +84,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -99,7 +99,7 @@ extension BaseCloudKitTests { 🗓️: 60 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -129,9 +129,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -144,7 +144,7 @@ extension BaseCloudKitTests { 🗓️: 60 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -181,9 +181,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -196,7 +196,7 @@ extension BaseCloudKitTests { 🗓️: 0 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -239,9 +239,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -254,7 +254,7 @@ extension BaseCloudKitTests { 🗓️: 30 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -284,9 +284,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -299,7 +299,7 @@ extension BaseCloudKitTests { 🗓️: 60 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -354,9 +354,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -369,7 +369,7 @@ extension BaseCloudKitTests { 🗓️: 60 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -431,9 +431,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -446,7 +446,7 @@ extension BaseCloudKitTests { 🗓️: 60 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -501,9 +501,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -516,7 +516,7 @@ extension BaseCloudKitTests { 🗓️: 60 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -572,9 +572,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, id🗓️: 0, @@ -587,7 +587,7 @@ extension BaseCloudKitTests { 🗓️: 60 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -649,9 +649,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, dueDate: Date(1970-01-01T00:00:30.000Z), dueDate🗓️: 1, @@ -668,7 +668,7 @@ extension BaseCloudKitTests { 🗓️: 2 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift index cc8e54f6..09295fec 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift @@ -28,9 +28,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -38,7 +38,7 @@ extension BaseCloudKitTests { title: "Groceries" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -46,7 +46,7 @@ extension BaseCloudKitTests { title: "Personal" ), [2]: CKRecord( - recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -98,9 +98,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -108,7 +108,7 @@ extension BaseCloudKitTests { title: "Groceries" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -116,7 +116,7 @@ extension BaseCloudKitTests { title: "Personal" ), [2]: CKRecord( - recordID: CKRecord.ID(2:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -153,7 +153,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminderTags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), recordType: "reminderTags", parent: nil, share: nil, @@ -162,9 +162,9 @@ extension BaseCloudKitTests { tagID: "weekend" ), [1]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -172,7 +172,7 @@ extension BaseCloudKitTests { title: "Groceries" ), [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -180,7 +180,7 @@ extension BaseCloudKitTests { title: "Personal" ), [3]: CKRecord( - recordID: CKRecord.ID(weekend:tags/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), recordType: "tags", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index cc143b6f..50d02d88 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -30,7 +30,7 @@ extension BaseCloudKitTests { try self.syncEngine.private.database.record( for: CKRecord.ID( recordName: "A", - zoneID: CKRecordZone.ID(zoneName: "zone") + zoneID: CKRecordZone.ID(zoneName: "unknownZone") ) ) } @@ -123,7 +123,7 @@ extension BaseCloudKitTests { @Test func saveInUnknownZone() async throws { let record = CKRecord( recordType: "Record", - recordID: CKRecord.ID(recordName: "Record", zoneID: CKRecordZone.ID(zoneName: "zone")) + recordID: CKRecord.ID(recordName: "Record", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) ) let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( @@ -208,7 +208,7 @@ extension BaseCloudKitTests { @Test func deleteRecordInUnknownZone() async throws { let record = CKRecord( recordType: "A", - recordID: CKRecord.ID(recordName: "A", zoneID: CKRecordZone.ID(zoneName: "zone")) + recordID: CKRecord.ID(recordName: "A", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) ) let (_, deleteResults) = try syncEngine.private.database.modifyRecords( @@ -283,10 +283,10 @@ extension BaseCloudKitTests { @Test func deleteUnknownZone() async throws { let (_, deleteResults) = try syncEngine.private.database.modifyRecordZones( saving: [], - deleting: [CKRecordZone.ID(zoneName: "zone")] + deleting: [CKRecordZone.ID(zoneName: "unknownZone")] ) let error = #expect(throws: CKError.self) { - try deleteResults[CKRecordZone.ID(zoneName: "zone")]?.get() + try deleteResults[CKRecordZone.ID(zoneName: "unknownZone")]?.get() } #expect(error == CKError(.zoneNotFound)) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 72e4ddbc..7b41ce04 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -33,9 +33,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -43,7 +43,7 @@ extension BaseCloudKitTests { title: "Write blog post" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -74,15 +74,15 @@ extension BaseCloudKitTests { parentRecordType: "remindersLists", parentRecordName: "1:remindersLists", lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil ), _lastKnownServerRecordAllFields: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -102,13 +102,13 @@ extension BaseCloudKitTests { parentRecordType: nil, parentRecordName: nil, lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil ), _lastKnownServerRecordAllFields: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 8c5056b0..2e3d2a88 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -108,7 +108,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -143,9 +143,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -153,7 +153,7 @@ extension BaseCloudKitTests { title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -188,16 +188,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersListPrivates/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, position: 42, remindersListID: 1 ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift index 6c2bd323..f27f8188 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift @@ -50,7 +50,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -123,16 +123,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + 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/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -199,36 +199,36 @@ extension BaseCloudKitTests { try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), - share: nil, - id: 1, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__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: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 @@ -281,14 +281,14 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/zone/__defaultOwner__), recordType: "childWithOnDeleteSetNulls", parent: nil, share: nil, id: 1 ), [1]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:parents/zone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, @@ -359,22 +359,22 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteSetDefaults/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:childWithOnDeleteSetDefaults/zone/__defaultOwner__), recordType: "childWithOnDeleteSetDefaults", - parent: CKReference(recordID: CKRecord.ID(0:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(0:parents/zone/__defaultOwner__)), share: nil, id: 1, parentID: 0 ), [1]: CKRecord( - recordID: CKRecord.ID(0:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(0:parents/zone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, id: 0 ), [2]: CKRecord( - recordID: CKRecord.ID(1:parents/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:parents/zone/__defaultOwner__), recordType: "parents", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift index 3207d88b..9a3afcfd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift @@ -1,5 +1,6 @@ import CloudKit import CustomDump +import GRDB import Foundation import InlineSnapshotTesting import OrderedCollections diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 2b66ceaf..7a1e633a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -490,16 +490,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), recordType: "cloudkit.share", parent: nil, share: nil ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) ) ] ), @@ -552,10 +552,10 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) ) ] ), @@ -576,16 +576,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), recordType: "cloudkit.share", parent: nil, share: nil ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) ) ] ), diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift index 769d8c2c..702f112e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -62,9 +62,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -72,7 +72,7 @@ extension BaseCloudKitTests { title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -160,7 +160,7 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, @@ -316,16 +316,16 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), recordType: "cloudkit.share", parent: nil, share: nil ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) ) ] ), @@ -417,9 +417,9 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)), + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, isCompleted: 0, @@ -427,7 +427,7 @@ extension BaseCloudKitTests { title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index e18de701..c879e3c1 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -702,6 +702,258 @@ extension BaseCloudKitTests { END """, [39]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetDefaults" + AFTER UPDATE OF "id" ON "childWithOnDeleteSetDefaults" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [40]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetNulls" + AFTER UPDATE OF "id" ON "childWithOnDeleteSetNulls" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [41]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelAs" + AFTER UPDATE OF "id" ON "modelAs" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [42]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelBs" + AFTER UPDATE OF "id" ON "modelBs" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [43]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelCs" + AFTER UPDATE OF "id" ON "modelCs" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [44]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_parents" + AFTER UPDATE OF "id" ON "parents" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [45]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminderTags" + AFTER UPDATE OF "id" ON "reminderTags" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); + END + """, + [46]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminders" + AFTER UPDATE OF "id" ON "reminders" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [47]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListAssets" + AFTER UPDATE OF "id" ON "remindersListAssets" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [48]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListPrivates" + AFTER UPDATE OF "id" ON "remindersListPrivates" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [49]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersLists" + AFTER UPDATE OF "id" ON "remindersLists" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [50]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_tags" + AFTER UPDATE OF "title" ON "tags" + FOR EACH ROW WHEN ("old"."title" <> "new"."title") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [51]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -724,7 +976,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [40]: """ + [52]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -747,7 +999,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [41]: """ + [53]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" AFTER UPDATE ON "modelAs" FOR EACH ROW BEGIN @@ -770,7 +1022,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [42]: """ + [54]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" AFTER UPDATE ON "modelBs" FOR EACH ROW BEGIN @@ -793,7 +1045,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [43]: """ + [55]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" AFTER UPDATE ON "modelCs" FOR EACH ROW BEGIN @@ -816,7 +1068,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [44]: """ + [56]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -839,7 +1091,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [45]: """ + [57]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -862,7 +1114,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [46]: """ + [58]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -885,7 +1137,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [47]: """ + [59]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -908,7 +1160,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [48]: """ + [60]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -931,7 +1183,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [49]: """ + [61]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -954,7 +1206,7 @@ extension BaseCloudKitTests { DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; END """, - [50]: """ + [62]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift index 786fde92..7f8eb99e 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift @@ -141,7 +141,7 @@ extension SyncEngine { syncEngines.shared as! MockSyncEngine } static nonisolated let defaultTestZone = CKRecordZone( - zoneName: "co.pointfree.SQLiteData.defaultZone" + zoneName: "zone" ) convenience init( container: any CloudContainer, From 2928dcc053e6e67255e4df7d9de2dc91c09135f8 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:02:46 -0500 Subject: [PATCH 507/581] Validate foreign keys point to synchronized tables. (#152) --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 154 +++++++++--------- .../SyncEngineValidationTests.swift | 66 ++++++++ 2 files changed, 147 insertions(+), 73 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index feac3959..296ccabf 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -177,11 +177,6 @@ } } ) - try validateSchema( - tables: tables, - foreignKeysByTableName: foreignKeysByTableName, - userDatabase: userDatabase - ) self.container = container self.defaultZone = defaultZone self.defaultSyncEngines = defaultSyncEngines @@ -206,6 +201,7 @@ tables: tables, tablesByName: tablesByName ) + try validateSchema() } @TaskLocal package static var _isSynchronizingChanges = false @@ -518,7 +514,7 @@ changes.append(.deleteRecord(share.recordID)) } guard isRunning else { - Task { [changes] in + Task { [changes] in await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.write { db in try PendingRecordZoneChange @@ -1273,7 +1269,9 @@ else { continue } func open(_: T.Type) async throws { do { - let serverRecord = try await container.sharedCloudDatabase.record(for: failedRecord.recordID) + let serverRecord = try await container.sharedCloudDatabase.record( + for: failedRecord.recordID + ) upsertFromServerRecord(serverRecord, force: true) } catch let error as CKError where error.code == .unknownItem { try await userDatabase.write { db in @@ -1818,6 +1816,7 @@ struct SchemaError: LocalizedError { enum Reason { case inMemoryDatabase + case invalidForeignKey(ForeignKey) case invalidForeignKeyAction(ForeignKey) case invalidTableName(String) case metadatabaseMismatch(attachedPath: String, syncEngineConfiguredPath: String) @@ -1832,6 +1831,78 @@ "Could not synchronize data with iCloud." } } + + fileprivate func validateSchema() throws { + let tableNames = Set(tables.map { $0.tableName }) + for tableName in tableNames { + if tableName.contains(":") { + throw SyncEngine.SchemaError( + reason: .invalidTableName(tableName), + debugDescription: "Table name contains invalid character ':'" + ) + } + } + try userDatabase.read { db in + for (tableName, foreignKeys) in foreignKeysByTableName { + + let invalidForeignKey = foreignKeys.first(where: { tablesByName[$0.table] == nil }) + if let invalidForeignKey { + throw SyncEngine.SchemaError( + reason: .invalidForeignKey(invalidForeignKey), + debugDescription: """ + Foreign key \(tableName.debugDescription).\(invalidForeignKey.from.debugDescription) \ + references table \(invalidForeignKey.table.debugDescription) that is not \ + synchronized. Update 'SyncEngine.init' to synchronize \ + \(invalidForeignKey.table.debugDescription). + """ + ) + } + + if foreignKeys.count == 1, + let foreignKey = foreignKeys.first, + [.restrict, .noAction].contains(foreignKey.onDelete) + { + throw SyncEngine.SchemaError( + reason: .invalidForeignKeyAction(foreignKey), + debugDescription: """ + Foreign key \(tableName.debugDescription).\(foreignKey.from.debugDescription) action \ + not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'. + """ + ) + } + } + + for table in tables { + // // TODO: write tests for this + // let columnsWithUniqueConstraints = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + // WHERE "unique" = 1 AND "origin" <> 'pk' + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !columnsWithUniqueConstraints.isEmpty { + // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) + // } + + // // TODO: write tests for this + // let nonNullColumnsWithNoDefault = + // try SQLQueryExpression( + // """ + // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) + // WHERE "notnull" = 1 AND "dflt_value" IS NULL + // """, + // as: String.self + // ) + // .fetchAll(db) + // if !nonNullColumnsWithNoDefault.isEmpty { + // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) + // } + } + } + } } // TODO: Private, opaque error @@ -1856,69 +1927,6 @@ // } // } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - private func validateSchema( - tables: [any PrimaryKeyedTable.Type], - foreignKeysByTableName: [String: [ForeignKey]], - userDatabase: UserDatabase - ) throws { - let tableNames = Set(tables.map { $0.tableName }) - for tableName in tableNames { - if tableName.contains(":") { - throw SyncEngine.SchemaError( - reason: .invalidTableName(tableName), - debugDescription: "Table name contains invalid character ':'" - ) - } - } - try userDatabase.read { db in - for (tableName, foreignKeys) in foreignKeysByTableName { - if foreignKeys.count == 1, - let foreignKey = foreignKeys.first, - [.restrict, .noAction].contains(foreignKey.onDelete) - { - throw SyncEngine.SchemaError( - reason: .invalidForeignKeyAction(foreignKey), - debugDescription: """ - Foreign key \(tableName.debugDescription).\(foreignKey.from.debugDescription) action \ - not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'. - """ - ) - } - } - - for table in tables { - // // TODO: write tests for this - // let columnsWithUniqueConstraints = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) - // WHERE "unique" = 1 AND "origin" <> 'pk' - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !columnsWithUniqueConstraints.isEmpty { - // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) - // } - - // // TODO: write tests for this - // let nonNullColumnsWithNoDefault = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) - // WHERE "notnull" = 1 AND "dflt_value" IS NULL - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !nonNullColumnsWithNoDefault.isEmpty { - // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) - // } - } - } - } - private struct HashablePrimaryKeyedTableType: Hashable { let type: any PrimaryKeyedTable.Type init(_ type: any PrimaryKeyedTable.Type) { @@ -2027,9 +2035,9 @@ columnNames .filter { columnName in columnName != T.columns.primaryKey.name } .map { - """ - \(quote: $0) = "excluded".\(quote: $0) - """ + """ + \(quote: $0) = "excluded".\(quote: $0) + """ } .joined(separator: ", ") ) diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index d0905fdb..2c741ae0 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -167,6 +167,72 @@ extension BaseCloudKitTests { } } + @Table struct Child: Identifiable { + let id: Int + var parentID: Parent.ID + } + @Table struct Parent: Identifiable { + let id: Int + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeyPointsToOtherSynchronizedTable() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "childs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [Child.self] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .invalidForeignKey( + ForeignKey( + table: "parents", + from: "parentID", + to: "id", + onUpdate: .noAction, + onDelete: .cascade, + notnull: false + ) + ), + debugDescription: #"Foreign key "childs"."parentID" references table "parents" that is not synchronized. Update 'SyncEngine.init' to synchronize "parents". "# + ) + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func doNotValidateTriggersOnNonSyncedTables() async throws { let database = try DatabaseQueue( From 86cf754e03095c0f8f05087011e09244324f5201 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 13:31:33 -0700 Subject: [PATCH 508/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 37 +++++++++++++++---- .../CloudKit/PendingRecordZoneChange.swift | 10 ++++- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 726609fe..c4d578ca 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -35,8 +35,16 @@ public struct _SystemFieldsRepresentation: QueryBindable, Quer self.queryOutput = queryOutput } + public init?(queryBinding: QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) + } + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let data = try Data(decoder: &decoder) + try self.init(data: try Data(decoder: &decoder)) + } + + private init(data: Data) throws { let coder = try NSKeyedUnarchiver(forReadingFrom: data) coder.requiresSecureCoding = true guard let queryOutput = Record(coder: coder) else { @@ -68,8 +76,16 @@ package struct _AllFieldsRepresentation: QueryBindable, QueryR self.queryOutput = queryOutput } + package init?(queryBinding: QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) + } + package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let data = try Data(decoder: &decoder) + try self.init(data: try Data(decoder: &decoder)) + } + + private init(data: Data) throws { let coder = try NSKeyedUnarchiver(forReadingFrom: data) coder.requiresSecureCoding = true guard let queryOutput = Record(coder: coder) else { @@ -98,15 +114,20 @@ extension CKDatabase.Scope { public init(queryOutput: CKDatabase.Scope) { self.queryOutput = queryOutput } + public init?(queryBinding: QueryBinding) { + guard case .int(let rawValue) = queryBinding else { return nil } + try? self.init(rawValue: Int(rawValue)) + } public init(decoder: inout some QueryDecoder) throws { - guard - let rawValue = try Int?(decoder: &decoder), - let scope = CKDatabase.Scope(rawValue: rawValue) - else { - throw QueryDecodingError.missingRequiredColumn + try self.init(rawValue: Int(decoder: &decoder)) + } + private init(rawValue: Int) throws { + guard let queryOutput = CKDatabase.Scope(rawValue: rawValue) else { + throw DecodingError() } - self.init(queryOutput: scope) + self.init(queryOutput: queryOutput) } + private struct DecodingError: Error {} } } diff --git a/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift index 7db94d26..03481e57 100644 --- a/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift +++ b/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift @@ -39,8 +39,16 @@ return archiver.encodedData.queryBinding } + package init?(queryBinding: StructuredQueriesCore.QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) + } + package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let data = try Data(decoder: &decoder) + try self.init(data: Data(decoder: &decoder)) + } + + private init(data: Data) throws { let coder = try NSKeyedUnarchiver(forReadingFrom: data) coder.requiresSecureCoding = true guard let recordID = CKRecord.ID(coder: coder) else { From 9d8def08ad95f83dc768b1bb38af85ab51547295 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:03:31 -0500 Subject: [PATCH 509/581] Lots of updates to docs. (#153) * Lots of updates to docs. * db error * fixes --- .../CloudKit/CloudKitSharing.swift | 30 +++-- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 100 +++++---------- .../Documentation.docc/Articles/CloudKit.md | 118 ++++++++++++++---- .../Articles/CloudKitSharing.md | 58 ++++++++- .../SharingGRDBCore/Internal/Exports.swift | 5 +- .../SyncEngineValidationTests.swift | 67 ++++++++-- 6 files changed, 251 insertions(+), 127 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index a74e54b5..4a945c38 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -185,34 +185,30 @@ let availablePermissions: UICloudSharingController.PermissionOptions let didFinish: (Result) -> Void let didStopSharing: () -> Void - public init( - sharedRecord: SharedRecord, - availablePermissions: UICloudSharingController.PermissionOptions = [] - ) { - self.init( - sharedRecord: sharedRecord, - availablePermissions: availablePermissions, - didFinish: { _ in }, - didStopSharing: {} - ) - } + let syncEngine: SyncEngine public init( sharedRecord: SharedRecord, availablePermissions: UICloudSharingController.PermissionOptions = [], - didFinish: @escaping (Result) -> Void, - didStopSharing: @escaping () -> Void + didFinish: @escaping (Result) -> Void = { _ in }, + didStopSharing: @escaping () -> Void = { }, + syncEngine: SyncEngine = { + @Dependency(\.defaultSyncEngine) var defaultSyncEngine + return defaultSyncEngine + }() ) { self.sharedRecord = sharedRecord self.didFinish = didFinish self.didStopSharing = didStopSharing self.availablePermissions = availablePermissions + self.syncEngine = syncEngine } public func makeCoordinator() -> CloudSharingDelegate { CloudSharingDelegate( share: sharedRecord.share, didFinish: didFinish, - didStopSharing: didStopSharing + didStopSharing: didStopSharing, + syncEngine: syncEngine ) } @@ -238,14 +234,17 @@ let share: CKShare let didFinish: (Result) -> Void let didStopSharing: () -> Void + let syncEngine: SyncEngine init( share: CKShare, didFinish: @escaping (Result) -> Void, - didStopSharing: @escaping () -> Void + didStopSharing: @escaping () -> Void, + syncEngine: SyncEngine ) { self.share = share self.didFinish = didFinish self.didStopSharing = didStopSharing + self.syncEngine = syncEngine } public func itemThumbnailData(for csc: UICloudSharingController) -> Data? { @@ -261,7 +260,6 @@ } public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { - @Dependency(\.defaultSyncEngine) var syncEngine withErrorReporting(.sqliteDataCloudKitFailure) { try syncEngine.deleteShare(recordID: share.recordID) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 296ccabf..f78e26dc 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -158,21 +158,17 @@ tables: [any PrimaryKeyedTable.Type], privateTables: [any PrimaryKeyedTable.Type] = [] ) throws { - let allTables = try userDatabase.read { db in - try SQLQueryExpression( - """ - SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' - """, - as: String.self - ) - .fetchAll(db) - } + let allTables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) + .map(\.type) + self.tables = allTables + self.privateTables = privateTables + let foreignKeysByTableName = Dictionary( uniqueKeysWithValues: try userDatabase.read { db in try allTables.map { table -> (String, [ForeignKey]) in ( - table, - try ForeignKey.all(table).fetchAll(db) + table.tableName, + try ForeignKey.all(table.tableName).fetchAll(db) ) } } @@ -189,16 +185,11 @@ containerIdentifier: container.containerIdentifier ) ) - let tables = Set((tables + privateTables).map(HashablePrimaryKeyedTableType.init)) - .map(\.type) - self.tables = tables - self.privateTables = privateTables - self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = foreignKeysByTableName tablesByOrder = try SharingGRDBCore.tablesByOrder( userDatabase: userDatabase, - tables: tables, + tables: allTables, tablesByName: tablesByName ) try validateSchema() @@ -1755,10 +1746,14 @@ /// } /// ``` /// + /// By default this method will use the container identifier assigned in your app's + /// entitlements. If you wish to use a different container identifier then you can provide + /// the `containerIdentifier` argument. + /// /// See for more information on preparing your database. /// - /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize - /// data. + /// - Parameter containerIdentifier: The identifier of the CloudKit container used to + /// synchronize data. Defaults to the value set in the app's entitlements. public func attachMetadatabase(containerIdentifier: String? = nil) throws { let containerIdentifier = containerIdentifier @@ -1823,6 +1818,7 @@ case noCloudKitContainer case nonNullColumnsWithoutDefault(tableName: String, columnNames: [String]) case unknown + case uniquenessConstraint } let reason: Reason let debugDescription: String @@ -1873,60 +1869,28 @@ } for table in tables { - // // TODO: write tests for this - // let columnsWithUniqueConstraints = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) - // WHERE "unique" = 1 AND "origin" <> 'pk' - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !columnsWithUniqueConstraints.isEmpty { - // throw UniqueConstraintDisallowed(table: table, columns: columnsWithUniqueConstraints) - // } - - // // TODO: write tests for this - // let nonNullColumnsWithNoDefault = - // try SQLQueryExpression( - // """ - // SELECT "name" FROM pragma_table_info(\(quote: table.tableName, delimiter: .text)) - // WHERE "notnull" = 1 AND "dflt_value" IS NULL - // """, - // as: String.self - // ) - // .fetchAll(db) - // if !nonNullColumnsWithNoDefault.isEmpty { - // throw NonNullColumnMustHaveDefault(table: table, columns: nonNullColumnsWithNoDefault) - // } + let columnsWithUniqueConstraints = + try SQLQueryExpression( + """ + SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + WHERE "unique" = 1 AND "origin" <> 'pk' + """, + as: String.self + ) + .fetchAll(db) + if !columnsWithUniqueConstraints.isEmpty { + throw SyncEngine.SchemaError( + reason: .uniquenessConstraint, + debugDescription: """ + Uniqueness constraints are not supported for synchronized tables. + """ + ) + } } } } } - // TODO: Private, opaque error - // public struct UniqueConstraintDisallowed: Error { - // let localizedDescription: String - // init(table: any PrimaryKeyedTable.Type, columns: [String]) { - // localizedDescription = """ - // Table '\(table.tableName)' has column\(columns.count == 1 ? "" : "s") with unique \ - // constraints: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - // """ - // } - // } - - // TODO: Private, opaque error - // public struct NonNullColumnMustHaveDefault: Error { - // let localizedDescription: String - // init(table: any PrimaryKeyedTable.Type, columns: [String]) { - // localizedDescription = """ - // Table '\(table.tableName)' has non-null column\(columns.count == 1 ? "" : "s") with no \ - // default: \(columns.map { "'\($0)'" }.joined(separator: ", ")) - // """ - // } - // } - private struct HashablePrimaryKeyedTableType: Hashable { let type: any PrimaryKeyedTable.Type init(_ type: any PrimaryKeyedTable.Type) { diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index 98d0c80e..986fadb9 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -183,6 +183,9 @@ CREATE TABLE "reminders" ( ) ``` +> Tip: If you want the database to generate random UUID's in a deterministic fashion for tests +> you can register a custom database function to be used. + #### Primary keys on every table > TL;DR: Each synchronized table must have a single, non-compound primary key to aid in @@ -204,24 +207,6 @@ CREATE TABLE "reminderTags" ( Note that the `id` column might not be needed for your application's logic, but it is necessary to facilitate synchronizing to CloudKit. - +when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be +thrown. #### Foreign key relationships @@ -289,7 +273,7 @@ has been added to the schema, it will populate the table with the cached records #### Adding columns -> TL;DR: When adding columns to a table that has already been deployed to user's devices, you will +> TL;DR: When adding columns to a table that has already been deployed to users' devices, you will either need to make the column nullable, or it can be `NOT NULL` but a default value must be provided with an `ON CONFLICT REPLACE` clause. @@ -491,7 +475,8 @@ exposed for you to query it in whichever way you want. > Important: In order to query the `SyncMetadata` table from your database connection you will need to attach the metadatabase to your database connection. This can be done with the -``GRDB/Database/attachMetadatabase(containerIdentifier:)`` method defined on `Database`. +``GRDB/Database/attachMetadatabase(containerIdentifier:)`` method defined on `Database`. See + for more information on how to do this. With that done you can use the ``StructuredQueriesCore/PrimaryKeyedTable/metadata(for:)`` method to construct a SQL query for fetching the meta data associated with one of your records. @@ -506,6 +491,7 @@ let lastKnownServerRecord = try database.read { db in .metadata(for: remindersListID) .select(\.lastKnownServerRecord) .fetchOne(db) + ?? nil } guard let lastKnownServerRecord else { return } @@ -544,10 +530,32 @@ let ckRecord = try await container.sharedCloudDatabase appropriate to use when fetching the details of a `CKShare` as they are always stored in the shared database. - +It is also possible to join the ``SyncMetadata`` table directly to your tables so that you can +select this additional information on a per-record basis. For example, if you want to select all +reminders lists, along with a boolean that determines if it is shared or not, you can do the +following: + +```swift +@Selection struct Row { + let remindersList: RemindersList + let isShared: Bool +} + +@FetchAll( + RemindersList + .leftJoin(SyncMetadata.all) { $0.recordName.eq($1.recordName) } + .select { + Row.Columns( + remindersList: $0, + isShared: $1.isShared ?? false + ) + } +) +var rows +``` + +Here we have used the ``StructuredQueriesCore/PrimaryKeyedTableDefinition/recordName`` helper that +is defined on all primary key tables so that we can join ``SyncMetadata`` to `RemindersList`. ## How SharingGRDB handles distributed schema scenarios @@ -555,6 +563,62 @@ TODO: finish ## Unit testing and Xcode previews +It is possible to run your features in tests and previews even when using the ``SyncEngine``. You +will need to prepare it for dependencies exactly as you do in the entry point of your app. This +can lead to some code duplication, and so you may want to extract that work to a mutating +`bootstrapDatabase` method on `DependencyValues` like so: + +```swift +extension DependencyValues { + mutating func bootstrapDatabase() throws { + defaultDatabase = try Reminders.appDatabase() + defaultSyncEngine = try SyncEngine( + for: defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self + ) + } +} +``` + +Then in your app entry point you can use it like so: + +```swift +@main +struct MyApp: App { + init() { + try! prepareDependencies { + try! $0.bootstrapDatabase() + } + } + + // ... +} +``` + +In tests you can use it like so: + +```swift +@Suite(.dependencies { try! $0.bootstrapDatabase() }) +struct MySuite { + // ... +} +``` + +And in preivews you can use it like so: + +```swift +#Preview { + try! prepareDependencies { + try! $0.bootstrapDatabase() + } + // ... +} +``` + ## Preparing an existing schema for synchronization diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md index c9b68901..19d8efd1 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md @@ -15,7 +15,17 @@ Info.plist with a value of `true`. This is subtly documented in [Apple's documen [Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic -TODO: ToC + - [Creating CKShare records](#Creating-CKShare-records) + - [Accepting shared records](#Accepting-shared-records) + - [Diving deeper into sharing](#Diving-deeper-into-sharing) + - [Sharing root records](#Sharing-root-records) + - [Sharing foreign key relationships](#Sharing-foreign-key-relationships) + - [One-to-many relationships](#One-to-many-relationships) + - [Many-to-many relationships](#Many-to-many-relationships) + - [One-to-"at most one" relationships](#One-to-at-most-one-relationships) + - [Sharing permissions](#Sharing-permissions) + - [Controlling what data is shared](#Controlling-what-data-is-shared) + - [Querying share metadata](#Querying-share-metadata) ## Creating CKShare records @@ -353,7 +363,51 @@ it is also the primary key of the table it enforces that at most one cover image ## Sharing permissions -TODO: finish +CloudKit sharing supports permissions so that you can give read-only or read-write access to the +data you share with other users. These permissions are automatically observed by the library and +enforced when writing to your database. If your application tries to write to a record that it +does not have permission for, a `DatabaseError` will be emitted. + +To check for this error you can catch `DatabaseError` and compare its message to +``SyncEngine/writePermissionError``: + +```swift +do { + try await database.write { db in + Reminder.find(id) + .update { $0.title = "Personal" } + .execute(db) + } +} catch let error as DatabaseError where error.message == SyncEngine.writePermissionError { + // User does not have permission to write to this record. +} +``` + +See for more information on accessing the metadata +associationed with your user's data. + +Ideally your app would not allow the user to write to records that they do not have permissions for. +To check their permissions for a record, you can join the root record table to +``SyncMetadata`` and select the ``SyncMetadata/share`` value: + +```swift +let share = try await database.read { db in + RemindersList + .metadata(for: id) + .select(\.share) + .fetchOne(db) + ?? nil +} +guard + share?.currentUserParticipant?.permission == .readWrite + || share?.permission == .readWrite +else { + // User does not have permissions to write to record. + return +} +``` + +This allows you to determine the sharing permissions for a root record. ## Controlling what data is shared diff --git a/Sources/SharingGRDBCore/Internal/Exports.swift b/Sources/SharingGRDBCore/Internal/Exports.swift index 591037e3..df2a2de6 100644 --- a/Sources/SharingGRDBCore/Internal/Exports.swift +++ b/Sources/SharingGRDBCore/Internal/Exports.swift @@ -2,11 +2,12 @@ @_exported import Sharing @_exported import StructuredQueriesGRDBCore +@_exported import struct GRDB.Configuration @_exported import class GRDB.Database +@_exported import struct GRDB.DatabaseError +@_exported import struct GRDB.DatabaseMigrator @_exported import class GRDB.DatabasePool @_exported import class GRDB.DatabaseQueue @_exported import protocol GRDB.DatabaseReader @_exported import protocol GRDB.DatabaseWriter @_exported import protocol GRDB.ValueObservationScheduler -@_exported import struct GRDB.Configuration -@_exported import struct GRDB.DatabaseMigrator diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift index 2c741ae0..528ec81f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift @@ -49,9 +49,7 @@ extension BaseCloudKitTests { @Test func foreignKeyActionValidation_NoAction() async throws { let error = try #require( await #expect(throws: (any Error).self) { - var configuration = Configuration() - configuration.foreignKeysEnabled = false - let database = try DatabaseQueue(configuration: configuration) + let database = try DatabaseQueue() try await database.write { db in try #sql( """ @@ -63,7 +61,7 @@ extension BaseCloudKitTests { .execute(db) try #sql( """ - CREATE TABLE "children" ( + CREATE TABLE "childs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE NO ACTION ) STRICT @@ -78,7 +76,7 @@ extension BaseCloudKitTests { sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) ), userDatabase: UserDatabase(database: database), - tables: [] + tables: [Child.self, Parent.self] ) } ) @@ -100,7 +98,7 @@ extension BaseCloudKitTests { notnull: false ) ), - debugDescription: #"Foreign key "children"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# + debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# ) """ } @@ -110,9 +108,7 @@ extension BaseCloudKitTests { @Test func foreignKeyActionValidation_Restrict() async throws { let error = try #require( await #expect(throws: (any Error).self) { - var configuration = Configuration() - configuration.foreignKeysEnabled = false - let database = try DatabaseQueue(configuration: configuration) + let database = try DatabaseQueue() try await database.write { db in try #sql( """ @@ -124,7 +120,7 @@ extension BaseCloudKitTests { .execute(db) try #sql( """ - CREATE TABLE "children" ( + CREATE TABLE "childs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE RESTRICT ) STRICT @@ -139,7 +135,7 @@ extension BaseCloudKitTests { sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) ), userDatabase: UserDatabase(database: database), - tables: [] + tables: [Parent.self, Child.self] ) } ) @@ -161,7 +157,7 @@ extension BaseCloudKitTests { notnull: false ) ), - debugDescription: #"Foreign key "children"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# + debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# ) """ } @@ -279,5 +275,52 @@ extension BaseCloudKitTests { tables: [] ) } + + @Table struct ModelWithUniqueColumn { + let id: Int + let uniqueValue: Int + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func uniquenessConstraint() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "modelWithUniqueColumns" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uniqueValue" INTEGER NOT NULL, + UNIQUE("uniqueValue") + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [ModelWithUniqueColumn.self] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .uniquenessConstraint, + debugDescription: "Uniqueness constraints are not supported for synchronized tables." + ) + """ + } + } } } From c1b93836098d113b86fdb686aebf49ab1c9170d6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 2 Sep 2025 16:03:57 -0500 Subject: [PATCH 510/581] Beginning of renaming package to SQLiteData. --- Examples/CaseStudies/Animations.swift | 2 +- Examples/CaseStudies/App.swift | 2 +- Examples/CaseStudies/DynamicQuery.swift | 2 +- .../CaseStudies/ObservableModelDemo.swift | 2 +- .../CaseStudies/SwiftDataTemplateDemo.swift | 2 +- Examples/CaseStudies/SwiftUIDemo.swift | 2 +- Examples/CaseStudies/TransactionDemo.swift | 2 +- Examples/CaseStudies/UIKitDemo.swift | 2 +- Examples/CloudKitDemo/CloudKitDemoApp.swift | 2 +- .../CloudKitDemo/CountersListFeature.swift | 2 +- Examples/CloudKitDemo/Schema.swift | 2 +- .../CloudKitPlaygroundApp.swift | 2 +- Examples/CloudKitPlayground/ModelAView.swift | 2 +- Examples/CloudKitPlayground/ModelBView.swift | 2 +- Examples/CloudKitPlayground/ModelCView.swift | 2 +- Examples/CloudKitPlayground/Schema.swift | 2 +- Examples/Reminders/Helpers.swift | 2 +- Examples/Reminders/ReminderForm.swift | 2 +- Examples/Reminders/ReminderRow.swift | 2 +- Examples/Reminders/RemindersApp.swift | 2 +- Examples/Reminders/RemindersDetail.swift | 2 +- Examples/Reminders/RemindersListForm.swift | 2 +- Examples/Reminders/RemindersListRow.swift | 2 +- Examples/Reminders/RemindersLists.swift | 2 +- Examples/Reminders/Schema.swift | 2 +- Examples/Reminders/SearchReminders.swift | 2 +- Examples/Reminders/TagRow.swift | 2 +- Examples/Reminders/TagsForm.swift | 2 +- Examples/RemindersTests/Internal.swift | 2 +- Examples/SyncUpTests/Internal.swift | 2 +- Examples/SyncUps/App.swift | 2 +- Examples/SyncUps/Schema.swift | 2 +- Examples/SyncUps/SyncUpDetail.swift | 2 +- Examples/SyncUps/SyncUpForm.swift | 2 +- Examples/SyncUps/SyncUpsApp.swift | 2 +- Examples/SyncUps/SyncUpsList.swift | 2 +- Package.swift | 40 +++++++-------- Package@swift-6.0.swift | 30 ++++++------ README.md | 46 +++++++++--------- .../Documentation.docc/SharingGRDB.md | 0 .../{SharingGRDB => SQLiteData}/Exports.swift | 2 +- .../CloudKit/CloudContainer.swift | 0 .../CloudKit/CloudDatabase.swift | 0 .../CloudKit/CloudKit+StructuredQueries.swift | 0 .../CloudKit/CloudKitSharing.swift | 0 .../CloudKit/DefaultSyncEngine.swift | 0 .../CloudKit/ForeignKey.swift | 0 .../IdentifierStringConvertible.swift | 0 .../CloudKit/Internal/DatetimeGenerator.swift | 0 .../CloudKit/Internal/IsolatedWeakVar.swift | 0 .../Internal/MockCloudContainer.swift | 0 .../CloudKit/Internal/MockCloudDatabase.swift | 0 .../CloudKit/Internal/MockSyncEngine.swift | 0 .../CloudKit/Logging.swift | 0 .../CloudKit/Metadatabase.swift | 0 ...ndingRecordZoneChange+MacroExpansion.swift | 0 .../CloudKit/PendingRecordZoneChange.swift | 0 .../CloudKit/RecordType+MacroExpansion.swift | 0 .../CloudKit/RecordType.swift | 0 .../CloudKit/SQLiteSchema.swift | 0 .../StateSerialization+MacroExpansion.swift | 0 .../CloudKit/StateSerialization.swift | 0 .../CloudKit/SyncEngine.Event.swift | 0 .../CloudKit/SyncEngine.swift | 2 +- .../CloudKit/SyncEngineProtocol+Live.swift | 0 .../CloudKit/SyncEngineProtocol.swift | 0 .../SyncMetadata+MacroExpansion.swift | 0 .../CloudKit/SyncMetadata.swift | 0 .../CloudKit/TableInfo.swift | 0 .../CloudKit/Triggers.swift | 0 .../UnsyncedRecordID+MacroExpansion.swift | 0 .../CloudKit/UnsyncedRecordID.swift | 0 .../Documentation.docc/Articles/CloudKit.md | 0 .../Articles/CloudKitSharing.md | 0 .../Articles/ComparisonWithSwiftData.md | 0 .../Articles/Deprecations.md | 0 .../Articles/DynamicQueries.md | 0 .../Documentation.docc/Articles/Fetching.md | 0 .../Articles/MigrationGuides.md | 0 .../MigrationGuides/MigratingTo0.2.md | 0 .../Documentation.docc/Articles/Observing.md | 0 .../Articles/PreparingDatabase.md | 8 +-- .../Documentation.docc/Extensions/Fetch.md | 0 .../Documentation.docc/Extensions/FetchAll.md | 0 .../Documentation.docc/Extensions/FetchKey.md | 0 .../Extensions/FetchKeyRequest.md | 0 .../Documentation.docc/Extensions/FetchOne.md | 0 .../sync-diagram-many-to-many-refactor.png | Bin .../Resources/sync-diagram-many-to-many.png | Bin ...sync-diagram-one-to-at-most-one-unique.png | Bin .../Resources/sync-diagram-one-to-many.png | Bin .../Resources/sync-diagram-root-record.png | Bin .../Documentation.docc/SharingGRDBCore.md | 2 +- .../Fetch.swift | 0 .../FetchAll.swift | 0 .../FetchKey+SwiftUI.swift | 0 .../FetchKey.swift | 0 .../FetchKeyRequest.swift | 0 .../FetchOne.swift | 0 .../Internal/DataManager.swift | 0 .../Internal/Deprecations.swift | 0 .../Internal/Exports.swift | 0 .../Internal/StatementKey.swift | 0 .../Internal/UserDatabase.swift | 0 .../Traits/Tagged.swift | 0 .../AssertQuery.swift | 0 .../StructuredQueriesGRDB.md | 2 +- .../DefaultDatabase.swift | 2 +- .../StructuredQueriesGRDBCore.md | 2 +- .../AssertQueryTests.swift | 4 +- .../CloudKitTests/AccountLifecycleTests.swift | 2 +- .../CloudKitTests/AssetsTests.swift | 2 +- .../CloudKitTests/CloudKitTests.swift | 2 +- .../FetchRecordZoneChangesTests.swift | 2 +- .../FetchedDatabaseChangesTests.swift | 2 +- .../ForeignKeyConstraintTests.swift | 2 +- .../CloudKitTests/MergeConflictTests.swift | 2 +- .../CloudKitTests/MetadataTests.swift | 2 +- .../MockCloudDatabaseTests.swift | 2 +- .../CloudKitTests/NewTableSyncTests.swift | 2 +- .../NextRecordZoneChangeBatchTests.swift | 2 +- .../CloudKitTests/RecordTypeTests.swift | 2 +- .../ReferenceViolationTests.swift | 2 +- .../CloudKitTests/SchemaChangeTests.swift | 2 +- .../SharingPermissionsTests.swift | 2 +- .../CloudKitTests/SharingTests.swift | 2 +- .../SyncEngineLifecycleTests.swift | 2 +- .../CloudKitTests/SyncEngineTests.swift | 2 +- .../SyncEngineValidationTests.swift | 2 +- .../CloudKitTests/TriggerTests.swift | 2 +- .../CloudKitTests/UserlandTests.swift | 4 +- .../FetchAllTests.swift | 2 +- .../FetchOneTests.swift | 2 +- .../FetchTests.swift | 2 +- .../IntegrationTests.swift | 2 +- .../Internal/AccountStatusScope.swift | 0 .../Internal/BaseCloudKitTests.swift | 4 +- .../Internal/CloudKit+CustomDump.swift | 2 +- .../Internal/CloudKitTestHelpers.swift | 2 +- .../Internal/PrintTimestampsScope.swift | 0 .../Internal/Schema.swift | 2 +- .../Internal/UserDatabaseHelpers.swift | 2 +- .../SharingGRDBTests.swift | 2 +- 143 files changed, 139 insertions(+), 139 deletions(-) rename Sources/{SharingGRDB => SQLiteData}/Documentation.docc/SharingGRDB.md (100%) rename Sources/{SharingGRDB => SQLiteData}/Exports.swift (54%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/CloudContainer.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/CloudDatabase.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/CloudKit+StructuredQueries.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/CloudKitSharing.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/DefaultSyncEngine.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/ForeignKey.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/IdentifierStringConvertible.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/Internal/DatetimeGenerator.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/Internal/IsolatedWeakVar.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/Internal/MockCloudContainer.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/Internal/MockCloudDatabase.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/Internal/MockSyncEngine.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/Logging.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/Metadatabase.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/PendingRecordZoneChange+MacroExpansion.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/PendingRecordZoneChange.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/RecordType+MacroExpansion.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/RecordType.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/SQLiteSchema.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/StateSerialization+MacroExpansion.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/StateSerialization.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/SyncEngine.Event.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/SyncEngine.swift (99%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/SyncEngineProtocol+Live.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/SyncEngineProtocol.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/SyncMetadata+MacroExpansion.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/SyncMetadata.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/TableInfo.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/Triggers.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/UnsyncedRecordID+MacroExpansion.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/CloudKit/UnsyncedRecordID.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/CloudKit.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/CloudKitSharing.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/ComparisonWithSwiftData.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/Deprecations.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/DynamicQueries.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/Fetching.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/MigrationGuides.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/Observing.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Articles/PreparingDatabase.md (99%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Extensions/Fetch.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Extensions/FetchAll.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Extensions/FetchKey.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Extensions/FetchKeyRequest.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Extensions/FetchOne.md (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Resources/sync-diagram-many-to-many.png (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Resources/sync-diagram-one-to-many.png (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/Resources/sync-diagram-root-record.png (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Documentation.docc/SharingGRDBCore.md (99%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Fetch.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/FetchAll.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/FetchKey+SwiftUI.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/FetchKey.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/FetchKeyRequest.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/FetchOne.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Internal/DataManager.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Internal/Deprecations.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Internal/Exports.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Internal/StatementKey.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Internal/UserDatabase.swift (100%) rename Sources/{SharingGRDBCore => SQLiteDataCore}/Traits/Tagged.swift (100%) rename Sources/{SharingGRDBTestSupport => SQLiteDataTestSupport}/AssertQuery.swift (100%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/AssertQueryTests.swift (98%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/AccountLifecycleTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/AssetsTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/CloudKitTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/FetchRecordZoneChangesTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/FetchedDatabaseChangesTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/ForeignKeyConstraintTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/MergeConflictTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/MetadataTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/MockCloudDatabaseTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/NewTableSyncTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/NextRecordZoneChangeBatchTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/RecordTypeTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/ReferenceViolationTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/SchemaChangeTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/SharingPermissionsTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/SharingTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/SyncEngineLifecycleTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/SyncEngineTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/SyncEngineValidationTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/TriggerTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/UserlandTests.swift (88%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/FetchAllTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/FetchOneTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/FetchTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/IntegrationTests.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/Internal/AccountStatusScope.swift (100%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/Internal/BaseCloudKitTests.swift (98%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/Internal/CloudKit+CustomDump.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/Internal/CloudKitTestHelpers.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/Internal/PrintTimestampsScope.swift (100%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/Internal/Schema.swift (99%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/Internal/UserDatabaseHelpers.swift (98%) rename Tests/{SharingGRDBTests => SQLiteDataTests}/SharingGRDBTests.swift (99%) diff --git a/Examples/CaseStudies/Animations.swift b/Examples/CaseStudies/Animations.swift index d5380c34..dcb9fb08 100644 --- a/Examples/CaseStudies/Animations.swift +++ b/Examples/CaseStudies/Animations.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct AnimationsCaseStudy: SwiftUICaseStudy { diff --git a/Examples/CaseStudies/App.swift b/Examples/CaseStudies/App.swift index 6463b491..160275c3 100644 --- a/Examples/CaseStudies/App.swift +++ b/Examples/CaseStudies/App.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI @main diff --git a/Examples/CaseStudies/DynamicQuery.swift b/Examples/CaseStudies/DynamicQuery.swift index dd2d5f40..afdf7e8d 100644 --- a/Examples/CaseStudies/DynamicQuery.swift +++ b/Examples/CaseStudies/DynamicQuery.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct DynamicQueryDemo: SwiftUICaseStudy { diff --git a/Examples/CaseStudies/ObservableModelDemo.swift b/Examples/CaseStudies/ObservableModelDemo.swift index a777fb64..4d697b40 100644 --- a/Examples/CaseStudies/ObservableModelDemo.swift +++ b/Examples/CaseStudies/ObservableModelDemo.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct ObservableModelDemo: SwiftUICaseStudy { diff --git a/Examples/CaseStudies/SwiftDataTemplateDemo.swift b/Examples/CaseStudies/SwiftDataTemplateDemo.swift index 3d14347a..6e8b5700 100644 --- a/Examples/CaseStudies/SwiftDataTemplateDemo.swift +++ b/Examples/CaseStudies/SwiftDataTemplateDemo.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct SwiftDataTemplateView: SwiftUICaseStudy { diff --git a/Examples/CaseStudies/SwiftUIDemo.swift b/Examples/CaseStudies/SwiftUIDemo.swift index d4a12335..7d13978a 100644 --- a/Examples/CaseStudies/SwiftUIDemo.swift +++ b/Examples/CaseStudies/SwiftUIDemo.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct SwiftUIDemo: SwiftUICaseStudy { diff --git a/Examples/CaseStudies/TransactionDemo.swift b/Examples/CaseStudies/TransactionDemo.swift index ddb629b7..f186e9dd 100644 --- a/Examples/CaseStudies/TransactionDemo.swift +++ b/Examples/CaseStudies/TransactionDemo.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct TransactionDemo: SwiftUICaseStudy { diff --git a/Examples/CaseStudies/UIKitDemo.swift b/Examples/CaseStudies/UIKitDemo.swift index 88951175..95d2160d 100644 --- a/Examples/CaseStudies/UIKitDemo.swift +++ b/Examples/CaseStudies/UIKitDemo.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftNavigation import SwiftUI import UIKit diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index 6fb3c017..40b628ce 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -1,5 +1,5 @@ import CloudKit -import SharingGRDB +import SQLiteData import SwiftUI #if canImport diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index a553228f..a356784d 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -1,5 +1,5 @@ import CloudKit -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 97357251..c495166d 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -1,6 +1,6 @@ import Foundation import OSLog -import SharingGRDB +import SQLiteData @Table struct Counter: Identifiable { diff --git a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift index b9010b25..c4ee8a3b 100644 --- a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift +++ b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift @@ -1,5 +1,5 @@ import CloudKit -import SharingGRDB +import SQLiteData import SwiftUI import UIKit diff --git a/Examples/CloudKitPlayground/ModelAView.swift b/Examples/CloudKitPlayground/ModelAView.swift index 5ad88823..3835281d 100644 --- a/Examples/CloudKitPlayground/ModelAView.swift +++ b/Examples/CloudKitPlayground/ModelAView.swift @@ -1,5 +1,5 @@ import CloudKit -import SharingGRDB +import SQLiteData import SwiftUI struct ModelAView: View { diff --git a/Examples/CloudKitPlayground/ModelBView.swift b/Examples/CloudKitPlayground/ModelBView.swift index f34c0091..2a426f47 100644 --- a/Examples/CloudKitPlayground/ModelBView.swift +++ b/Examples/CloudKitPlayground/ModelBView.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct ModelBView: View { diff --git a/Examples/CloudKitPlayground/ModelCView.swift b/Examples/CloudKitPlayground/ModelCView.swift index 329a622b..6687f9d5 100644 --- a/Examples/CloudKitPlayground/ModelCView.swift +++ b/Examples/CloudKitPlayground/ModelCView.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct ModelCView: View { diff --git a/Examples/CloudKitPlayground/Schema.swift b/Examples/CloudKitPlayground/Schema.swift index 9b38d3f6..1b6f323f 100644 --- a/Examples/CloudKitPlayground/Schema.swift +++ b/Examples/CloudKitPlayground/Schema.swift @@ -1,5 +1,5 @@ import Foundation -import SharingGRDB +import SQLiteData import os @Table struct ModelA: Identifiable { diff --git a/Examples/Reminders/Helpers.swift b/Examples/Reminders/Helpers.swift index 6b61b4e0..3cb6028f 100644 --- a/Examples/Reminders/Helpers.swift +++ b/Examples/Reminders/Helpers.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI extension Color { diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 6fd8a52f..8a88abde 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -1,5 +1,5 @@ import IssueReporting -import SharingGRDB +import SQLiteData import SwiftUI struct ReminderFormView: View { diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index 0522c1e0..c67f50bc 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct ReminderRow: View { diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 14cd2e38..1f05cf24 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,7 +1,7 @@ import CloudKit import Combine import Dependencies -import SharingGRDB +import SQLiteData import SwiftUI import UIKit diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index a4e94c0c..4e8a534f 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,6 +1,6 @@ import CasePaths import CloudKit -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 7f4f6dff..31dde0e3 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -1,6 +1,6 @@ import IssueReporting import PhotosUI -import SharingGRDB +import SQLiteData import SwiftUI struct RemindersListForm: View { diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index b6a46715..c2778bf8 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -1,5 +1,5 @@ import CloudKit -import SharingGRDB +import SQLiteData import SwiftUI struct RemindersListRow: View { diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index ccd2580d..ccde9af6 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -1,5 +1,5 @@ import CloudKit -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation import TipKit diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index ec79f5be..6f576520 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -2,7 +2,7 @@ import Dependencies import Foundation import IssueReporting import OSLog -import SharingGRDB +import SQLiteData import SwiftUI @Table diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index e2b3e14e..4b8c936b 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -1,5 +1,5 @@ import IssueReporting -import SharingGRDB +import SQLiteData import SwiftUI @MainActor diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index 42b126f5..37ae1e0b 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI struct TagRow: View { diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index b1342219..7beb0b96 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index 35986dd8..5ee171dc 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -1,6 +1,6 @@ import CustomDump import Foundation -import SharingGRDB +import SQLiteData import SwiftUI import Testing diff --git a/Examples/SyncUpTests/Internal.swift b/Examples/SyncUpTests/Internal.swift index 1939607e..c3d9251f 100644 --- a/Examples/SyncUpTests/Internal.swift +++ b/Examples/SyncUpTests/Internal.swift @@ -1,5 +1,5 @@ import Foundation -import SharingGRDB +import SQLiteData @testable import SyncUps diff --git a/Examples/SyncUps/App.swift b/Examples/SyncUps/App.swift index a3b6a0fb..bce63a1c 100644 --- a/Examples/SyncUps/App.swift +++ b/Examples/SyncUps/App.swift @@ -1,5 +1,5 @@ import CasePaths -import SharingGRDB +import SQLiteData import SwiftUI @MainActor diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index 2d66f05d..04d95d6d 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -1,5 +1,5 @@ import OSLog -import SharingGRDB +import SQLiteData import SwiftUI @Table diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index 93d344af..f1568169 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation diff --git a/Examples/SyncUps/SyncUpForm.swift b/Examples/SyncUps/SyncUpForm.swift index 369b61b7..664a2a56 100644 --- a/Examples/SyncUps/SyncUpForm.swift +++ b/Examples/SyncUps/SyncUpForm.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation diff --git a/Examples/SyncUps/SyncUpsApp.swift b/Examples/SyncUps/SyncUpsApp.swift index 4f8135a4..97e1cd4c 100644 --- a/Examples/SyncUps/SyncUpsApp.swift +++ b/Examples/SyncUps/SyncUpsApp.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI @main diff --git a/Examples/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUpsList.swift index 175deae6..7efa1295 100644 --- a/Examples/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUpsList.swift @@ -1,4 +1,4 @@ -import SharingGRDB +import SQLiteData import SwiftUI import SwiftUINavigation import TipKit diff --git a/Package.swift b/Package.swift index 9cab1d88..bc9b5291 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( - name: "sharing-grdb", + name: "sqlite-data", platforms: [ .iOS(.v13), .macOS(.v10_15), @@ -12,16 +12,16 @@ let package = Package( ], products: [ .library( - name: "SharingGRDB", - targets: ["SharingGRDB"] + name: "SQLiteData", + targets: ["SQLiteData"] ), .library( - name: "SharingGRDBCore", - targets: ["SharingGRDBCore"] + name: "SQLiteDataCore", + targets: ["SQLiteDataCore"] ), .library( - name: "SharingGRDBTestSupport", - targets: ["SharingGRDBTestSupport"] + name: "SQLiteDataTestSupport", + targets: ["SQLiteDataTestSupport"] ), .library( name: "StructuredQueriesGRDB", @@ -34,10 +34,10 @@ let package = Package( ], traits: [ .trait( - name: "SharingGRDBTagged", - description: "Introduce SharingGRDB conformances to the swift-tagged package." + name: "SQLiteDataTagged", + description: "Introduce SQLiteData conformances to the swift-tagged package." ), - .default(enabledTraits: ["SharingGRDBTagged"]), + .default(enabledTraits: ["SQLiteDataTagged"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), @@ -50,7 +50,7 @@ let package = Package( url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.16.0", traits: [ - .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SharingGRDBTagged"])), + .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SQLiteDataTagged"])), ] ), .package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.10.0"), @@ -58,14 +58,14 @@ let package = Package( ], targets: [ .target( - name: "SharingGRDB", + name: "SQLiteData", dependencies: [ - "SharingGRDBCore", + "SQLiteDataCore", "StructuredQueriesGRDB", ] ), .target( - name: "SharingGRDBCore", + name: "SQLiteDataCore", dependencies: [ "StructuredQueriesGRDBCore", .product(name: "GRDB", package: "GRDB.swift"), @@ -75,15 +75,15 @@ let package = Package( .product( name: "Tagged", package: "swift-tagged", - condition: .when(traits: ["SharingGRDBTagged"]) + condition: .when(traits: ["SQLiteDataTagged"]) ), ] ), .testTarget( - name: "SharingGRDBTests", + name: "SQLiteDataTests", dependencies: [ - "SharingGRDB", - "SharingGRDBTestSupport", + "SQLiteData", + "SQLiteDataTestSupport", .product(name: "DependenciesTestSupport", package: "swift-dependencies"), .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), @@ -91,9 +91,9 @@ let package = Package( ] ), .target( - name: "SharingGRDBTestSupport", + name: "SQLiteDataTestSupport", dependencies: [ - "SharingGRDB", + "SQLiteData", .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "StructuredQueriesTestSupport", package: "swift-structured-queries"), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index c664a0ca..0d31a75b 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( - name: "sharing-grdb", + name: "sqlite-data", platforms: [ .iOS(.v13), .macOS(.v10_15), @@ -12,16 +12,16 @@ let package = Package( ], products: [ .library( - name: "SharingGRDB", - targets: ["SharingGRDB"] + name: "SQLiteData", + targets: ["SQLiteData"] ), .library( - name: "SharingGRDBCore", - targets: ["SharingGRDBCore"] + name: "SQLiteDataCore", + targets: ["SQLiteDataCore"] ), .library( - name: "SharingGRDBTestSupport", - targets: ["SharingGRDBTestSupport"] + name: "SQLiteDataTestSupport", + targets: ["SQLiteDataTestSupport"] ), .library( name: "StructuredQueriesGRDB", @@ -43,14 +43,14 @@ let package = Package( ], targets: [ .target( - name: "SharingGRDB", + name: "SQLiteData", dependencies: [ - "SharingGRDBCore", + "SQLiteDataCore", "StructuredQueriesGRDB", ] ), .target( - name: "SharingGRDBCore", + name: "SQLiteDataCore", dependencies: [ "StructuredQueriesGRDBCore", .product(name: "GRDB", package: "GRDB.swift"), @@ -58,18 +58,18 @@ let package = Package( ] ), .testTarget( - name: "SharingGRDBTests", + name: "SQLiteDataTests", dependencies: [ - "SharingGRDB", - "SharingGRDBTestSupport", + "SQLiteData", + "SQLiteDataTestSupport", .product(name: "DependenciesTestSupport", package: "swift-dependencies"), .product(name: "StructuredQueries", package: "swift-structured-queries"), ] ), .target( - name: "SharingGRDBTestSupport", + name: "SQLiteDataTestSupport", dependencies: [ - "SharingGRDB", + "SQLiteData", .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "StructuredQueriesTestSupport", package: "swift-structured-queries"), diff --git a/README.md b/README.md index 6ad58220..a834d4f6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [public beta]: https://github.com/pointfreeco/sharing-grdb/pull/112 -# SharingGRDB +# SQLiteData A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL and supporting CloudKit synchronization. @@ -39,13 +39,13 @@ library, [subscribe today](https://www.pointfree.co/pricing). ## Overview -SharingGRDB is a [fast](#performance), lightweight replacement for SwiftData that deploys all the +SQLiteData is a [fast](#performance), lightweight replacement for SwiftData that deploys all the way back to the iOS 13 generation of targets. To populate data from the database you can use the `@FetchAll` property wrapper, which is similar to SwiftData's `@Query` macro: - + @@ -94,22 +94,22 @@ class Item { Both of the above examples fetch items from an external data store using Swift data types, and both are automatically observed by SwiftUI so that views are recomputed when the external data changes, -but SharingGRDB is powered directly by SQLite using [Sharing][], [StructuredQueries][], and +but SQLiteData is powered directly by SQLite using [Sharing][], [StructuredQueries][], and [GRDB][], and is usable from UIKit, `@Observable` models, and more. -For more information on SharingGRDB's querying capabilities, see +For more information on SQLiteData's querying capabilities, see [Fetching model data][fetching-article]. ## Quick start -Before SharingGRDB's property wrappers can fetch data from SQLite, you need to provide–at +Before SQLiteData's property wrappers can fetch data from SQLite, you need to provide–at runtime–the default database it should use. This is typically done as early as possible in your app's lifetime, like the app entry point in SwiftUI, and is analogous to configuring model storage in SwiftData:
SharingGRDBSQLiteData SwiftData
- + @@ -159,13 +159,13 @@ struct MyApp: App { > For more information on preparing a SQLite database, see > [Preparing a SQLite database][preparing-db-article]. -This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like +This `defaultDatabase` connection is used implicitly by SQLiteData's strategies, like [`@FetchAll`][fetchall-docs] and [`@FetchOne`][fetchone-docs], which are similar to SwiftData's `@Query` macro, but more powerful:
SharingGRDBSQLiteData SwiftData
- + @@ -217,7 +217,7 @@ a model context, via a property wrapper:
SharingGRDBSQLiteData SwiftData
- + @@ -252,7 +252,7 @@ try modelContext.save()
SharingGRDBSQLiteData SwiftData
> [!NOTE] -> For more information on how SharingGRDB compares to SwiftData, see +> For more information on how SQLiteData compares to SwiftData, see > [Comparison with SwiftData][comparison-swiftdata-article]. Further, if you want to synchronize the local database to CloudKit so that it is available on @@ -278,7 +278,7 @@ struct MyApp: App { > For more information on synchronizing the database to CloudKit and sharing records with iCloud > users, see [CloudKit Synchronization]. -This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read +This is all you need to know to get started with SQLiteData, but there's much more to learn. Read the [articles][articles] below to learn how to best utilize this library: * [Fetching model data][fetching-article] @@ -300,7 +300,7 @@ the [articles][articles] below to learn how to best utilize this library: ## Performance -SharingGRDB leverages high-performance decoding from [StructuredQueries][] to turn fetched data into +SQLiteData leverages high-performance decoding from [StructuredQueries][] to turn fetched data into your Swift domain types, and has a performance profile similar to invoking SQLite's C APIs directly. See the following benchmarks against @@ -311,7 +311,7 @@ taste of how it compares: Orders.fetchAll setup rampup duration SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 Lighter (1.4.10) 0 0.164 8.059 - SharingGRDB (0.2.0) 0 0.172 8.511 + SQLiteData (0.2.0) 0 0.172 8.511 GRDB (7.4.1, manual decoding) 0 0.376 18.819 SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 SQLite.swift (0.15.3, Codable) 0 0.863 43.261 @@ -366,25 +366,25 @@ The documentation for releases and `main` are available here: ## Installation -You can add SharingGRDB to an Xcode project by adding it to your project as a package… +You can add SQLiteData to an Xcode project by adding it to your project as a package… > https://github.com/pointfreeco/sharing-grdb -…and adding the `SharingGRDB` product to your target. +…and adding the `SQLiteData` product to your target. > [!TIP] -> SharingGRDB's primary product is the `SharingGRDB` module, which includes all of the library's +> SQLiteData's primary product is the `SQLiteData` module, which includes all of the library's > functionality, including the `@Fetch` family of property wrappers, the `@Table` macro, and tools > for driving StructuredQueries using GRDB. This is the module that most library users should depend > on. > -> If you are a library author that wishes to extend SharingGRDB with additional functionality, you +> If you are a library author that wishes to extend SQLiteData with additional functionality, you > may want to depend on a different module: > -> * `SharingGRDBCore`: This product includes everything in `SharingGRDB` _except_ the macros -> (`@Table`, `#sql`, _etc._). This module can be imported to extend SharingGRDB with additional +> * `SQLiteDataCore`: This product includes everything in `SQLiteData` _except_ the macros +> (`@Table`, `#sql`, _etc._). This module can be imported to extend SQLiteData with additional > functionality without forcing the heavyweight dependency of SwiftSyntax on your users. -> * `StructuredQueriesGRDB`: This product includes everything in `SharingGRDB` _except_ the +> * `StructuredQueriesGRDB`: This product includes everything in `SQLiteData` _except_ the > `@Fetch` family of property wrappers. It can be imported if you want to extend > StructuredQueries' GRDB driver but do not need access to observation tools provided by > Sharing. @@ -393,7 +393,7 @@ You can add SharingGRDB to an Xcode project by adding it to your project as a pa > additional functionality without forcing the heavyweight dependency of SwiftSyntax on your > users. -If you want to use SharingGRDB in a [SwiftPM](https://swift.org/package-manager/) project, it's as +If you want to use SQLiteData in a [SwiftPM](https://swift.org/package-manager/) project, it's as simple as adding it to your `Package.swift`: ``` swift @@ -405,7 +405,7 @@ dependencies: [ And then adding the following product to any target that needs access to the library: ```swift -.product(name: "SharingGRDB", package: "sharing-grdb"), +.product(name: "SQLiteData", package: "sharing-grdb"), ``` ## Community diff --git a/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md b/Sources/SQLiteData/Documentation.docc/SharingGRDB.md similarity index 100% rename from Sources/SharingGRDB/Documentation.docc/SharingGRDB.md rename to Sources/SQLiteData/Documentation.docc/SharingGRDB.md diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SQLiteData/Exports.swift similarity index 54% rename from Sources/SharingGRDB/Exports.swift rename to Sources/SQLiteData/Exports.swift index 07a5c659..d8ac607a 100644 --- a/Sources/SharingGRDB/Exports.swift +++ b/Sources/SQLiteData/Exports.swift @@ -1,2 +1,2 @@ -@_exported import SharingGRDBCore +@_exported import SQLiteDataCore @_exported import StructuredQueriesGRDB diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SQLiteDataCore/CloudKit/CloudContainer.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/CloudContainer.swift rename to Sources/SQLiteDataCore/CloudKit/CloudContainer.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift b/Sources/SQLiteDataCore/CloudKit/CloudDatabase.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/CloudDatabase.swift rename to Sources/SQLiteDataCore/CloudKit/CloudDatabase.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteDataCore/CloudKit/CloudKit+StructuredQueries.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift rename to Sources/SQLiteDataCore/CloudKit/CloudKit+StructuredQueries.swift diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SQLiteDataCore/CloudKit/CloudKitSharing.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift rename to Sources/SQLiteDataCore/CloudKit/CloudKitSharing.swift diff --git a/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift b/Sources/SQLiteDataCore/CloudKit/DefaultSyncEngine.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift rename to Sources/SQLiteDataCore/CloudKit/DefaultSyncEngine.swift diff --git a/Sources/SharingGRDBCore/CloudKit/ForeignKey.swift b/Sources/SQLiteDataCore/CloudKit/ForeignKey.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/ForeignKey.swift rename to Sources/SQLiteDataCore/CloudKit/ForeignKey.swift diff --git a/Sources/SharingGRDBCore/CloudKit/IdentifierStringConvertible.swift b/Sources/SQLiteDataCore/CloudKit/IdentifierStringConvertible.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/IdentifierStringConvertible.swift rename to Sources/SQLiteDataCore/CloudKit/IdentifierStringConvertible.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift b/Sources/SQLiteDataCore/CloudKit/Internal/DatetimeGenerator.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/Internal/DatetimeGenerator.swift rename to Sources/SQLiteDataCore/CloudKit/Internal/DatetimeGenerator.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/IsolatedWeakVar.swift b/Sources/SQLiteDataCore/CloudKit/Internal/IsolatedWeakVar.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/Internal/IsolatedWeakVar.swift rename to Sources/SQLiteDataCore/CloudKit/Internal/IsolatedWeakVar.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteDataCore/CloudKit/Internal/MockCloudContainer.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift rename to Sources/SQLiteDataCore/CloudKit/Internal/MockCloudContainer.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteDataCore/CloudKit/Internal/MockCloudDatabase.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift rename to Sources/SQLiteDataCore/CloudKit/Internal/MockCloudDatabase.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteDataCore/CloudKit/Internal/MockSyncEngine.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift rename to Sources/SQLiteDataCore/CloudKit/Internal/MockSyncEngine.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Logging.swift b/Sources/SQLiteDataCore/CloudKit/Logging.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/Logging.swift rename to Sources/SQLiteDataCore/CloudKit/Logging.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SQLiteDataCore/CloudKit/Metadatabase.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/Metadatabase.swift rename to Sources/SQLiteDataCore/CloudKit/Metadatabase.swift diff --git a/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift b/Sources/SQLiteDataCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift rename to Sources/SQLiteDataCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift diff --git a/Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift b/Sources/SQLiteDataCore/CloudKit/PendingRecordZoneChange.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/PendingRecordZoneChange.swift rename to Sources/SQLiteDataCore/CloudKit/PendingRecordZoneChange.swift diff --git a/Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift b/Sources/SQLiteDataCore/CloudKit/RecordType+MacroExpansion.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/RecordType+MacroExpansion.swift rename to Sources/SQLiteDataCore/CloudKit/RecordType+MacroExpansion.swift diff --git a/Sources/SharingGRDBCore/CloudKit/RecordType.swift b/Sources/SQLiteDataCore/CloudKit/RecordType.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/RecordType.swift rename to Sources/SQLiteDataCore/CloudKit/RecordType.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SQLiteSchema.swift b/Sources/SQLiteDataCore/CloudKit/SQLiteSchema.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/SQLiteSchema.swift rename to Sources/SQLiteDataCore/CloudKit/SQLiteSchema.swift diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift b/Sources/SQLiteDataCore/CloudKit/StateSerialization+MacroExpansion.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/StateSerialization+MacroExpansion.swift rename to Sources/SQLiteDataCore/CloudKit/StateSerialization+MacroExpansion.swift diff --git a/Sources/SharingGRDBCore/CloudKit/StateSerialization.swift b/Sources/SQLiteDataCore/CloudKit/StateSerialization.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/StateSerialization.swift rename to Sources/SQLiteDataCore/CloudKit/StateSerialization.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift b/Sources/SQLiteDataCore/CloudKit/SyncEngine.Event.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/SyncEngine.Event.swift rename to Sources/SQLiteDataCore/CloudKit/SyncEngine.Event.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SQLiteDataCore/CloudKit/SyncEngine.swift similarity index 99% rename from Sources/SharingGRDBCore/CloudKit/SyncEngine.swift rename to Sources/SQLiteDataCore/CloudKit/SyncEngine.swift index f78e26dc..21ec74ab 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteDataCore/CloudKit/SyncEngine.swift @@ -187,7 +187,7 @@ ) self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = foreignKeysByTableName - tablesByOrder = try SharingGRDBCore.tablesByOrder( + tablesByOrder = try SQLiteDataCore.tablesByOrder( userDatabase: userDatabase, tables: allTables, tablesByName: tablesByName diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SQLiteDataCore/CloudKit/SyncEngineProtocol+Live.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol+Live.swift rename to Sources/SQLiteDataCore/CloudKit/SyncEngineProtocol+Live.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SQLiteDataCore/CloudKit/SyncEngineProtocol.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift rename to Sources/SQLiteDataCore/CloudKit/SyncEngineProtocol.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SQLiteDataCore/CloudKit/SyncMetadata+MacroExpansion.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift rename to Sources/SQLiteDataCore/CloudKit/SyncMetadata+MacroExpansion.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SQLiteDataCore/CloudKit/SyncMetadata.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift rename to Sources/SQLiteDataCore/CloudKit/SyncMetadata.swift diff --git a/Sources/SharingGRDBCore/CloudKit/TableInfo.swift b/Sources/SQLiteDataCore/CloudKit/TableInfo.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/TableInfo.swift rename to Sources/SQLiteDataCore/CloudKit/TableInfo.swift diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SQLiteDataCore/CloudKit/Triggers.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/Triggers.swift rename to Sources/SQLiteDataCore/CloudKit/Triggers.swift diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift b/Sources/SQLiteDataCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift rename to Sources/SQLiteDataCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift diff --git a/Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift b/Sources/SQLiteDataCore/CloudKit/UnsyncedRecordID.swift similarity index 100% rename from Sources/SharingGRDBCore/CloudKit/UnsyncedRecordID.swift rename to Sources/SQLiteDataCore/CloudKit/UnsyncedRecordID.swift diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKit.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKit.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKitSharing.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKitSharing.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/ComparisonWithSwiftData.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/ComparisonWithSwiftData.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/Deprecations.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/Deprecations.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/DynamicQueries.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/DynamicQueries.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/Fetching.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/Fetching.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/Observing.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/Observing.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/PreparingDatabase.md similarity index 99% rename from Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md rename to Sources/SQLiteDataCore/Documentation.docc/Articles/PreparingDatabase.md index 5d841fff..895e18a0 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Articles/PreparingDatabase.md @@ -62,7 +62,7 @@ when running your app in the simulator/device and using `Swift.print` in preview ```diff import OSLog - import SharingGRDB + import SQLiteData func appDatabase() -> any DatabaseWriter { + @Dependency(\.context) var context @@ -262,7 +262,7 @@ we have just written in one snippet: ```swift import OSLog -import SharingGRDB +import SQLiteData func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context @@ -313,7 +313,7 @@ it as the `defaultDatabase` for your app in its entry point. This can be in done `prepareDependencies` in the `init` of your `App` conformance: ```swift -import SharingGRDB +import SQLiteData import SwiftUI @main @@ -334,7 +334,7 @@ If using app or scene delegates, then you can prepare the `defaultDatabase` in o conformances: ```swift -import SharingGRDB +import SQLiteData import UIKit class AppDelegate: NSObject, UIApplicationDelegate { diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/Fetch.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md rename to Sources/SQLiteDataCore/Documentation.docc/Extensions/Fetch.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchAll.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md rename to Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchAll.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKey.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md rename to Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKey.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKeyRequest.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md rename to Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKeyRequest.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchOne.md similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md rename to Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchOne.md diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png b/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png rename to Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-many-to-many.png b/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-many-to-many.png similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-many-to-many.png rename to Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-many-to-many.png diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png b/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png rename to Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-many.png b/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-one-to-many.png similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-one-to-many.png rename to Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-one-to-many.png diff --git a/Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-root-record.png b/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-root-record.png similarity index 100% rename from Sources/SharingGRDBCore/Documentation.docc/Resources/sync-diagram-root-record.png rename to Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-root-record.png diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md similarity index 99% rename from Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md rename to Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md index f8f968c5..47a04e30 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md @@ -5,7 +5,7 @@ synchronization. ## Overview -> Important: This module is automatically imported when you `import SharingGRDB`. +> Important: This module is automatically imported when you `import SQLiteData`. SharingGRDB is a [fast](#Performance), lightweight replacement for SwiftData that deploys all the way back to the iOS 13 generation of targets. diff --git a/Sources/SharingGRDBCore/Fetch.swift b/Sources/SQLiteDataCore/Fetch.swift similarity index 100% rename from Sources/SharingGRDBCore/Fetch.swift rename to Sources/SQLiteDataCore/Fetch.swift diff --git a/Sources/SharingGRDBCore/FetchAll.swift b/Sources/SQLiteDataCore/FetchAll.swift similarity index 100% rename from Sources/SharingGRDBCore/FetchAll.swift rename to Sources/SQLiteDataCore/FetchAll.swift diff --git a/Sources/SharingGRDBCore/FetchKey+SwiftUI.swift b/Sources/SQLiteDataCore/FetchKey+SwiftUI.swift similarity index 100% rename from Sources/SharingGRDBCore/FetchKey+SwiftUI.swift rename to Sources/SQLiteDataCore/FetchKey+SwiftUI.swift diff --git a/Sources/SharingGRDBCore/FetchKey.swift b/Sources/SQLiteDataCore/FetchKey.swift similarity index 100% rename from Sources/SharingGRDBCore/FetchKey.swift rename to Sources/SQLiteDataCore/FetchKey.swift diff --git a/Sources/SharingGRDBCore/FetchKeyRequest.swift b/Sources/SQLiteDataCore/FetchKeyRequest.swift similarity index 100% rename from Sources/SharingGRDBCore/FetchKeyRequest.swift rename to Sources/SQLiteDataCore/FetchKeyRequest.swift diff --git a/Sources/SharingGRDBCore/FetchOne.swift b/Sources/SQLiteDataCore/FetchOne.swift similarity index 100% rename from Sources/SharingGRDBCore/FetchOne.swift rename to Sources/SQLiteDataCore/FetchOne.swift diff --git a/Sources/SharingGRDBCore/Internal/DataManager.swift b/Sources/SQLiteDataCore/Internal/DataManager.swift similarity index 100% rename from Sources/SharingGRDBCore/Internal/DataManager.swift rename to Sources/SQLiteDataCore/Internal/DataManager.swift diff --git a/Sources/SharingGRDBCore/Internal/Deprecations.swift b/Sources/SQLiteDataCore/Internal/Deprecations.swift similarity index 100% rename from Sources/SharingGRDBCore/Internal/Deprecations.swift rename to Sources/SQLiteDataCore/Internal/Deprecations.swift diff --git a/Sources/SharingGRDBCore/Internal/Exports.swift b/Sources/SQLiteDataCore/Internal/Exports.swift similarity index 100% rename from Sources/SharingGRDBCore/Internal/Exports.swift rename to Sources/SQLiteDataCore/Internal/Exports.swift diff --git a/Sources/SharingGRDBCore/Internal/StatementKey.swift b/Sources/SQLiteDataCore/Internal/StatementKey.swift similarity index 100% rename from Sources/SharingGRDBCore/Internal/StatementKey.swift rename to Sources/SQLiteDataCore/Internal/StatementKey.swift diff --git a/Sources/SharingGRDBCore/Internal/UserDatabase.swift b/Sources/SQLiteDataCore/Internal/UserDatabase.swift similarity index 100% rename from Sources/SharingGRDBCore/Internal/UserDatabase.swift rename to Sources/SQLiteDataCore/Internal/UserDatabase.swift diff --git a/Sources/SharingGRDBCore/Traits/Tagged.swift b/Sources/SQLiteDataCore/Traits/Tagged.swift similarity index 100% rename from Sources/SharingGRDBCore/Traits/Tagged.swift rename to Sources/SQLiteDataCore/Traits/Tagged.swift diff --git a/Sources/SharingGRDBTestSupport/AssertQuery.swift b/Sources/SQLiteDataTestSupport/AssertQuery.swift similarity index 100% rename from Sources/SharingGRDBTestSupport/AssertQuery.swift rename to Sources/SQLiteDataTestSupport/AssertQuery.swift diff --git a/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md b/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md index c62671c0..d7958ace 100644 --- a/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md +++ b/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md @@ -1,7 +1,7 @@ # ``StructuredQueriesGRDB`` A library interfacing StructuredQueries with GRDB. This module is automatically imported when you -`import SharingGRDB`. +`import SQLiteData`. ## Overview diff --git a/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift b/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift index b495710e..ded3f67a 100644 --- a/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift +++ b/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift @@ -8,7 +8,7 @@ extension DependencyValues { /// SwiftUI, using `prepareDependencies`: /// /// ```swift - /// import SharingGRDB + /// import SQLiteData /// import SwiftUI /// /// @main diff --git a/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md b/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md index be53d0f2..3f444f66 100644 --- a/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md +++ b/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md @@ -1,7 +1,7 @@ # ``StructuredQueriesGRDBCore`` The core functionality of interfacing StructuredQueries with GRDB. This module is automatically -imported when you `import SharingGRDB` or `StructuredQueriesGRDB`. +imported when you `import SQLiteData` or `StructuredQueriesGRDB`. ## Overview diff --git a/Tests/SharingGRDBTests/AssertQueryTests.swift b/Tests/SQLiteDataTests/AssertQueryTests.swift similarity index 98% rename from Tests/SharingGRDBTests/AssertQueryTests.swift rename to Tests/SQLiteDataTests/AssertQueryTests.swift index bebbb987..99285ca6 100644 --- a/Tests/SharingGRDBTests/AssertQueryTests.swift +++ b/Tests/SQLiteDataTests/AssertQueryTests.swift @@ -3,8 +3,8 @@ import DependenciesTestSupport import Foundation import GRDB import Sharing -import SharingGRDB -import SharingGRDBTestSupport +import SQLiteData +import SQLiteDataTestSupport import SnapshotTesting import StructuredQueries import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index 1819a06e..f9f18a31 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -3,7 +3,7 @@ import CustomDump import Foundation import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift index 31921095..58e9a87c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift @@ -3,7 +3,7 @@ import ConcurrencyExtras import CustomDump import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index d93c3386..2c70e50c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -3,7 +3,7 @@ import ConcurrencyExtras import CustomDump import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 509222d2..b414f90d 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -3,7 +3,7 @@ import CustomDump import Foundation import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SharingGRDBTestSupport import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift index 3d6c046d..40b05ff7 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/FetchedDatabaseChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift @@ -3,7 +3,7 @@ import CustomDump import Foundation import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index cf72e213..07692787 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -2,7 +2,7 @@ import CloudKit import CustomDump import Foundation import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 3663248d..6409914e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -3,7 +3,7 @@ import CustomDump import Foundation import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift index 09295fec..ea391dd9 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift @@ -3,7 +3,7 @@ import CustomDump import Foundation import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index 50d02d88..9a06ce0a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -3,7 +3,7 @@ import ConcurrencyExtras import CustomDump import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index 7b41ce04..40165898 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -2,7 +2,7 @@ import CloudKit import CustomDump import Foundation import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 2e3d2a88..6f1855df 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -2,7 +2,7 @@ import CloudKit import CustomDump import Foundation import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 2893c8bb..e00d74d2 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -2,7 +2,7 @@ import CloudKit import ConcurrencyExtras import CustomDump import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift index f27f8188..9d28e4ed 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift @@ -2,7 +2,7 @@ import CloudKit import ConcurrencyExtras import CustomDump import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index c0be6c99..a074adea 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -2,7 +2,7 @@ import CloudKit import CustomDump import Foundation import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift index 9a3afcfd..a65ce517 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift @@ -4,7 +4,7 @@ import GRDB import Foundation import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index 7a1e633a..f5536b3e 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -4,7 +4,7 @@ import GRDB import Foundation import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index 702f112e..ac9c3825 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -2,7 +2,7 @@ import CloudKit import DependenciesTestSupport import InlineSnapshotTesting import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTesting import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift index f7227cf9..55d97e1c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift @@ -3,7 +3,7 @@ import CustomDump import DependenciesTestSupport import Foundation import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift index 528ec81f..beede6e4 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift @@ -2,7 +2,7 @@ import CloudKit import CustomDump import Foundation import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift similarity index 99% rename from Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index c879e3c1..6b0be003 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -1,7 +1,7 @@ import CloudKit import CustomDump import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing diff --git a/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift similarity index 88% rename from Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift rename to Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift index a68b0ed6..04fdb92b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/UserlandTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift @@ -1,11 +1,11 @@ import Foundation import Testing -import SharingGRDB +import SQLiteData @Suite struct UserlandTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func basics() async throws { - let database = try SharingGRDBTests.database(containerIdentifier: "tests") + let database = try SQLiteDataTests.database(containerIdentifier: "tests") let syncEngine = try SyncEngine( for: database, tables: ModelA.self, diff --git a/Tests/SharingGRDBTests/FetchAllTests.swift b/Tests/SQLiteDataTests/FetchAllTests.swift similarity index 99% rename from Tests/SharingGRDBTests/FetchAllTests.swift rename to Tests/SQLiteDataTests/FetchAllTests.swift index 51569b75..6162755f 100644 --- a/Tests/SharingGRDBTests/FetchAllTests.swift +++ b/Tests/SQLiteDataTests/FetchAllTests.swift @@ -4,7 +4,7 @@ import DependenciesTestSupport import Foundation import GRDB import Sharing -import SharingGRDB +import SQLiteData import StructuredQueries import Testing diff --git a/Tests/SharingGRDBTests/FetchOneTests.swift b/Tests/SQLiteDataTests/FetchOneTests.swift similarity index 99% rename from Tests/SharingGRDBTests/FetchOneTests.swift rename to Tests/SQLiteDataTests/FetchOneTests.swift index 41cce897..e9e76956 100644 --- a/Tests/SharingGRDBTests/FetchOneTests.swift +++ b/Tests/SQLiteDataTests/FetchOneTests.swift @@ -4,7 +4,7 @@ import DependenciesTestSupport import Foundation import GRDB import Sharing -import SharingGRDB +import SQLiteData import StructuredQueries import Testing diff --git a/Tests/SharingGRDBTests/FetchTests.swift b/Tests/SQLiteDataTests/FetchTests.swift similarity index 99% rename from Tests/SharingGRDBTests/FetchTests.swift rename to Tests/SQLiteDataTests/FetchTests.swift index ecfcf946..b5987a27 100644 --- a/Tests/SharingGRDBTests/FetchTests.swift +++ b/Tests/SQLiteDataTests/FetchTests.swift @@ -2,7 +2,7 @@ import Dependencies import DependenciesTestSupport import GRDB import Sharing -import SharingGRDB +import SQLiteData import StructuredQueries import Testing diff --git a/Tests/SharingGRDBTests/IntegrationTests.swift b/Tests/SQLiteDataTests/IntegrationTests.swift similarity index 99% rename from Tests/SharingGRDBTests/IntegrationTests.swift rename to Tests/SQLiteDataTests/IntegrationTests.swift index 70787c89..6ab6d869 100644 --- a/Tests/SharingGRDBTests/IntegrationTests.swift +++ b/Tests/SQLiteDataTests/IntegrationTests.swift @@ -1,7 +1,7 @@ import Dependencies import DependenciesTestSupport import Sharing -import SharingGRDB +import SQLiteData import StructuredQueries import Testing diff --git a/Tests/SharingGRDBTests/Internal/AccountStatusScope.swift b/Tests/SQLiteDataTests/Internal/AccountStatusScope.swift similarity index 100% rename from Tests/SharingGRDBTests/Internal/AccountStatusScope.swift rename to Tests/SQLiteDataTests/Internal/AccountStatusScope.swift diff --git a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift similarity index 98% rename from Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift rename to Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 7f8eb99e..9c80b949 100644 --- a/Tests/SharingGRDBTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -1,7 +1,7 @@ import CloudKit import DependenciesTestSupport import OrderedCollections -import SharingGRDB +import SQLiteData import SnapshotTesting import Testing import os @@ -39,7 +39,7 @@ class BaseCloudKitTests: @unchecked Sendable { let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" self.userDatabase = UserDatabase( - database: try SharingGRDBTests.database(containerIdentifier: testContainerIdentifier) + database: try SQLiteDataTests.database(containerIdentifier: testContainerIdentifier) ) try await setUpUserDatabase(userDatabase) let privateDatabase = MockCloudDatabase(databaseScope: .private) diff --git a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift similarity index 99% rename from Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift rename to Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index fe14d470..83ba801d 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -1,7 +1,7 @@ #if canImport(CloudKit) import CustomDump import CloudKit - import SharingGRDB + import SQLiteData extension CKDatabase.Scope: @retroactive CustomDumpStringConvertible { public var customDumpDescription: String { diff --git a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift similarity index 99% rename from Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift rename to Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 1f2d1835..92b51982 100644 --- a/Tests/SharingGRDBTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -2,7 +2,7 @@ import CloudKit import ConcurrencyExtras import CustomDump import OrderedCollections -import SharingGRDBCore +import SQLiteDataCore import Testing @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/Internal/PrintTimestampsScope.swift b/Tests/SQLiteDataTests/Internal/PrintTimestampsScope.swift similarity index 100% rename from Tests/SharingGRDBTests/Internal/PrintTimestampsScope.swift rename to Tests/SQLiteDataTests/Internal/PrintTimestampsScope.swift diff --git a/Tests/SharingGRDBTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift similarity index 99% rename from Tests/SharingGRDBTests/Internal/Schema.swift rename to Tests/SQLiteDataTests/Internal/Schema.swift index c79e4da8..5471525c 100644 --- a/Tests/SharingGRDBTests/Internal/Schema.swift +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -1,5 +1,5 @@ import Foundation -import SharingGRDB +import SQLiteData // NB: The IDs in this schema are integers for ease of testing. You should _not_ use integer IDs // in a production application. diff --git a/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift b/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift similarity index 98% rename from Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift rename to Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift index 1f51b94b..cd48bfae 100644 --- a/Tests/SharingGRDBTests/Internal/UserDatabaseHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift @@ -1,5 +1,5 @@ import GRDB -import SharingGRDBCore +import SQLiteDataCore extension UserDatabase { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SharingGRDBTests/SharingGRDBTests.swift b/Tests/SQLiteDataTests/SharingGRDBTests.swift similarity index 99% rename from Tests/SharingGRDBTests/SharingGRDBTests.swift rename to Tests/SQLiteDataTests/SharingGRDBTests.swift index ffc088e5..adcfaa9e 100644 --- a/Tests/SharingGRDBTests/SharingGRDBTests.swift +++ b/Tests/SQLiteDataTests/SharingGRDBTests.swift @@ -2,7 +2,7 @@ import Dependencies import DependenciesTestSupport import GRDB import Sharing -import SharingGRDB +import SQLiteData import StructuredQueries import SwiftUI import Testing From a1cb207bfb63d49f77e71d4525db8d446ed011c1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 2 Sep 2025 16:16:20 -0500 Subject: [PATCH 511/581] Rename more things. --- Examples/CaseStudies/README.md | 2 +- Examples/README.md | 4 +- Examples/Reminders/README.md | 2 +- Examples/SyncUps/README.md | 4 +- README.md | 38 +++++------ .../Documentation.docc/SharingGRDB.md | 22 +++---- .../Documentation.docc/Articles/CloudKit.md | 18 ++--- .../Articles/CloudKitSharing.md | 4 +- .../Articles/ComparisonWithSwiftData.md | 66 +++++++++---------- .../Articles/DynamicQueries.md | 2 +- .../Articles/MigrationGuides.md | 4 +- .../MigrationGuides/MigratingTo0.2.md | 14 ++-- .../Documentation.docc/Articles/Observing.md | 2 +- .../Articles/PreparingDatabase.md | 2 +- .../Documentation.docc/Extensions/Fetch.md | 2 +- .../Documentation.docc/Extensions/FetchAll.md | 2 +- .../Documentation.docc/Extensions/FetchKey.md | 2 +- .../Extensions/FetchKeyRequest.md | 2 +- .../Documentation.docc/Extensions/FetchOne.md | 2 +- .../Documentation.docc/SharingGRDBCore.md | 34 +++++----- Sources/SQLiteDataCore/Traits/Tagged.swift | 2 +- .../DefaultDatabase.swift | 8 +-- .../StructuredQueriesGRDBCore.md | 2 +- .../QueryCursor.swift | 6 +- Tests/SharingGRDB.xctestplan | 8 +-- 25 files changed, 127 insertions(+), 127 deletions(-) diff --git a/Examples/CaseStudies/README.md b/Examples/CaseStudies/README.md index 2026a64f..4434a5b1 100644 --- a/Examples/CaseStudies/README.md +++ b/Examples/CaseStudies/README.md @@ -1,7 +1,7 @@ # Case Studies This project includes a number of digestible examples of how to solve common problems using -SharingGRDB, including: +SQLiteData, including: * [Animations](Animations.swift): Shows how to animate changes when the database changes and updates state in features. diff --git a/Examples/README.md b/Examples/README.md index 74cb3ab1..9a17af51 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -1,8 +1,8 @@ # Examples This directory holds many case studies and applications to demonstrate solving various problems -with [SharingGRDB](http://github.com/pointfreeco/sharing-grdb). Open the -`SharingGRDB.xcworkspace` at the root of the repo to see all example projects in one single +with [SQLiteData](http://github.com/pointfreeco/sqlite-data). Open the +`SQLiteData.xcworkspace` at the root of the repo to see all example projects in one single workspace, or you can open each example application individually. * **Case Studies** diff --git a/Examples/Reminders/README.md b/Examples/Reminders/README.md index 31e9c8d7..9ee145e4 100644 --- a/Examples/Reminders/README.md +++ b/Examples/Reminders/README.md @@ -10,4 +10,4 @@ comma-separated list of all of its tags. SQLite is an incredibly powerful langua not embrace abstractions that keep you from querying SQLite directly as SwiftData does. [reminders-app-store]: https://apps.apple.com/us/app/reminders/id1108187841 -[tags-concat]: https://github.com/pointfreeco/sharing-grdb/blob/0391201992241f62e7bd10c8d1ece63b078c16ad/Examples/Reminders/RemindersListDetail.swift#L146-L147 +[tags-concat]: https://github.com/pointfreeco/sqlite-data/blob/0391201992241f62e7bd10c8d1ece63b078c16ad/Examples/Reminders/RemindersListDetail.swift#L146-L147 diff --git a/Examples/SyncUps/README.md b/Examples/SyncUps/README.md index 192a72cb..17902dca 100644 --- a/Examples/SyncUps/README.md +++ b/Examples/SyncUps/README.md @@ -1,6 +1,6 @@ -# SyncUpsGRDB +# SyncUps -A version of [SyncUps][] that persists its model data using SharingGRDB. +A version of [SyncUps][] that persists its model data using SQLiteData. SyncUps is a rebuild of Apple's [Scrumdinger][] demo application, but with a focus on modern, best practices for SwiftUI development. diff --git a/README.md b/README.md index a834d4f6..452e9f5a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ > [!IMPORTANT] > We are currently running a [public beta] to preview our upcoming CloudKit synchronization tools. Get all the details [here](https://www.pointfree.co/blog/posts/181-a-swiftdata-alternative-with-sqlite-cloudkit-public-beta) and let us know if you have any feedback! -[public beta]: https://github.com/pointfreeco/sharing-grdb/pull/112 +[public beta]: https://github.com/pointfreeco/sqlite-data/pull/112 # SQLiteData A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL and supporting CloudKit synchronization. -[![CI](https://github.com/pointfreeco/sharing-grdb/actions/workflows/ci.yml/badge.svg)](https://github.com/pointfreeco/sharing-grdb/actions/workflows/ci.yml) +[![CI](https://github.com/pointfreeco/sqlite-data/actions/workflows/ci.yml/badge.svg)](https://github.com/pointfreeco/sqlite-data/actions/workflows/ci.yml) [![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.pointfree.co/slack-invite) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsharing-grdb%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/sharing-grdb) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsharing-grdb%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/sharing-grdb) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsqlite-data%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/sqlite-data) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsqlite-data%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/sqlite-data) * [Learn more](#Learn-more) * [Overview](#Overview) @@ -288,15 +288,15 @@ the [articles][articles] below to learn how to best utilize this library: * [CloudKit Synchronization] * [Comparison with SwiftData][comparison-swiftdata-article] -[observing-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/observing -[dynamic-queries-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/dynamicqueries -[articles]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore#Essentials -[comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/comparisonwithswiftdata -[fetching-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetching -[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/preparingdatabase -[CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/cloudkit -[fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetchall -[fetchone-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/fetchone +[observing-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/observing +[dynamic-queries-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/dynamicqueries +[articles]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore#Essentials +[comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/comparisonwithswiftdata +[fetching-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/fetching +[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/preparingdatabase +[CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/cloudkit +[fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/fetchall +[fetchone-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/fetchone ## Performance @@ -361,14 +361,14 @@ Sharing. Check out [this](./Examples) directory to see them all, including: The documentation for releases and `main` are available here: - * [`main`](https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdbcore/) - * [0.x.x](https://swiftpackageindex.com/pointfreeco/sharing-grdb/~/documentation/sharinggrdbcore/) + * [`main`](https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/) + * [0.x.x](https://swiftpackageindex.com/pointfreeco/sqlite-data/~/documentation/sqlitedatabcore/) ## Installation You can add SQLiteData to an Xcode project by adding it to your project as a package… -> https://github.com/pointfreeco/sharing-grdb +> https://github.com/pointfreeco/sqlite-data …and adding the `SQLiteData` product to your target. @@ -398,14 +398,14 @@ simple as adding it to your `Package.swift`: ``` swift dependencies: [ - .package(url: "https://github.com/pointfreeco/sharing-grdb", from: "0.6.0") + .package(url: "https://github.com/pointfreeco/sqlite-data", from: "0.6.0") ] ``` And then adding the following product to any target that needs access to the library: ```swift -.product(name: "SQLiteData", package: "sharing-grdb"), +.product(name: "SQLiteData", package: "sqlite-data"), ``` ## Community @@ -415,7 +415,7 @@ problem, there are a number of places you can discuss with fellow [Point-Free](http://www.pointfree.co) enthusiasts: * For long-form discussions, we recommend the - [discussions](http://github.com/pointfreeco/sharing-grdb/discussions) tab of this repo. + [discussions](http://github.com/pointfreeco/sqlite-data/discussions) tab of this repo. * For casual chat, we recommend the [Point-Free Community Slack](http://www.pointfree.co/slack-invite). diff --git a/Sources/SQLiteData/Documentation.docc/SharingGRDB.md b/Sources/SQLiteData/Documentation.docc/SharingGRDB.md index e72c2440..e474fe90 100644 --- a/Sources/SQLiteData/Documentation.docc/SharingGRDB.md +++ b/Sources/SQLiteData/Documentation.docc/SharingGRDB.md @@ -1,4 +1,4 @@ -# ``SharingGRDB`` +# ``SQLiteData`` A fast, lightweight replacement for SwiftData, powered by SQL and supporting CloudKit synchronization. @@ -6,36 +6,36 @@ synchronization. ## Overview The core functionality of this library is defined in -[`SharingGRDBCore`](sharinggrdbcore) and [`StructuredQueriesGRDBCore`](structuredquereisgrdbcore), +[`SQLiteDataCore`](sqlitedatacore) and [`StructuredQueriesGRDBCore`](structuredquereisgrdbcore), which this module automatically exports. > Note: This module also exports `StructuredQueries`, which provides the `@Table` macro for building > and decoding queries. If you are using [GRDB][]'s built-in tools instead of -> [StructuredQueries][], consider depending on `SharingGRDBCore`, instead. +> [StructuredQueries][], consider depending on `SQLiteDataCore`, instead. -See [`SharingGRDBCore`](sharinggrdbcore) for documentation on the integration with the +See [`SQLiteDataCore`](sqlitedatacore) for documentation on the integration with the `@FetchAll` property wrapper, which is equivalent to SwiftData's `@Query`. -See [`StructuredQueriesGRDBCore`](sharinggrdbcore) for documentation on the integration between +See [`StructuredQueriesGRDBCore`](sqlitedatacore) for documentation on the integration between [StructuredQueries][] and [GRDB][]. -> Tip: SharingGRDB's primary product is the `SharingGRDB` module, which includes all of the +> Tip: SQLiteData's primary product is the `SQLiteData` module, which includes all of the > library's functionality, including the `@Fetch` family of property wrappers, the `@Table` macro, > and tools for driving StructuredQueries using GRDB. This is the module that most library users > should depend on. > -> If you are a library author that wishes to extend SharingGRDB with additional functionality, you +> If you are a library author that wishes to extend SQLiteData with additional functionality, you > may want to depend on a different module: > -> * [`SharingGRDBCore`](sharinggrdbcore): This product includes everything in `SharingGRDB` +> * [`SQLiteDataCore`](sqlitedatacore): This product includes everything in `SQLiteData` > _except_ the macros (`@Table`, `#sql`, _etc._). This module can be imported to extend -> SharingGRDB with additional functionality without forcing the heavyweight dependency of +> SQLiteData with additional functionality without forcing the heavyweight dependency of > SwiftSyntax on your users. -> * `StructuredQueriesGRDB`: This product includes everything in `SharingGRDB` _except_ the +> * `StructuredQueriesGRDB`: This product includes everything in `SQLiteData` _except_ the > `@Fetch` family of property wrappers. It can be imported if you want to extend > StructuredQueries' GRDB driver but do not need access to observation tools provided by > Sharing. -> * [`StructuredQueriesGRDBCore`](sharinggrdbcore): This product includes everything in +> * [`StructuredQueriesGRDBCore`](sqlitedatacore): This product includes everything in > `StructuredQueriesGRDB` _except_ the macros. This module can be imported to extend > StructuredQueries' GRDB driver with additional functionality without forcing the heavyweight > dependency of SwiftSyntax on your users. diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKit.md index 986fadb9..83e3f70b 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKit.md @@ -1,10 +1,10 @@ # CloudKit synchronization -Learn how to seamlessly add CloudKit synchronization to your SharingGRDB application. +Learn how to seamlessly add CloudKit synchronization to your SQLiteData application. ## Overview -SharingGRDB allows you to seamlessly synchronize your SQLite database with CloudKit. After a few +SQLiteData allows you to seamlessly synchronize your SQLite database with CloudKit. After a few steps to set up your project and a ``SyncEngine``, your database can be automatically synchronized to CloudKit. However, distributing your app's schema across many devices is an impactful decision to make, and so an abundance of care must be taken to make sure all devices remain consistent @@ -25,12 +25,12 @@ to make sure you understand how to best prepare your app for cloud synchronizati * [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) * [Assets](#Assets) * [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) - * [How SharingGRDB handles distributed schema scenarios](#How-SharingGRDB-handles-distributed-schema-scenarios) + * [How SQLiteData handles distributed schema scenarios](#How-SQLiteData-handles-distributed-schema-scenarios) * [Unit testing and Xcode previews](#Unit-testing-and-Xcode-previews) * [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) * [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) * [Add primary key to all tables](#Add-primary-key-to-all-tables) - * [Migrating from Swift Data to SharingGRDB](#Migrating-from-Swift-Data-to-SharingGRDB) + * [Migrating from Swift Data to SQLiteData](#Migrating-from-Swift-Data-to-SQLiteData) * [Separating schema migrations from data migrations](#Separating-schema-migrations-from-data-migrations) * [Tips and tricks](#Tips-and-tricks) * [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization) @@ -39,7 +39,7 @@ to make sure you understand how to best prepare your app for cloud synchronizati ## Setting up your project -The steps to set up your SharingGRDB project for CloudKit synchronization are the +The steps to set up your SQLiteData project for CloudKit synchronization are the [same for setting up][setup-cloudkit-apple] any other kind of project for CloudKit: * Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project. @@ -228,7 +228,7 @@ thrown. > TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to > cascade deletions. -SharingGRDB can synchronize many-to-one and many-to-many relationships to CloudKit, +SQLiteData can synchronize many-to-one and many-to-many relationships to CloudKit, and you can enforce foreign key constraints in your database connection. While it is possible for the sync engine to receive records in an order that could cause a foreign key constraint failure, such as receiving a child record before its parent, the sync engine will cache the child record @@ -413,7 +413,7 @@ devices. They are: ## Sharing records with other iCloud users -SharingGRDB provides the tools necessary to share a record with another iCloud user so that +SQLiteData provides the tools necessary to share a record with another iCloud user so that multiple users can collaborate on a single record. Sharing a record with another user brings extra complications to an app that go beyond the existing complications of sharing a schema across many devices. Please read the documentation carefully and thoroughly to understand @@ -557,7 +557,7 @@ var rows Here we have used the ``StructuredQueriesCore/PrimaryKeyedTableDefinition/recordName`` helper that is defined on all primary key tables so that we can join ``SyncMetadata`` to `RemindersList`. -## How SharingGRDB handles distributed schema scenarios +## How SQLiteData handles distributed schema scenarios @@ -633,7 +633,7 @@ And in preivews you can use it like so: -## Migrating from Swift Data to SharingGRDB +## Migrating from Swift Data to SQLiteData ## Separating schema migrations from data migrations diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKitSharing.md index 19d8efd1..307eea8d 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKitSharing.md @@ -4,7 +4,7 @@ Learn how to allow your users to share certain records with other iCloud users f ## Overview -SharingGRDB provides the tools necessary to share a record with another iCloud user so that +SQLiteData provides the tools necessary to share a record with another iCloud user so that multiple users can collaborate on a single record. Sharing a record with another user brings extra complications to an app that go beyond the existing complications of sharing a schema across many devices. Please read the documentation carefully and thoroughly to understand @@ -29,7 +29,7 @@ Info.plist with a value of `true`. This is subtly documented in [Apple's documen ## Creating CKShare records -To share a record with another user one must first create a `CKShare`. SharingGRDB provides +To share a record with another user one must first create a `CKShare`. SQLiteData provides the method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` for a record. Further, the value returned from this method can be stored in a view and be used to drive a sheet to display a ``CloudSharingView``, which is a wrapper around UIKit's diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/ComparisonWithSwiftData.md index f94f87bf..c7b2d592 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -1,10 +1,10 @@ # Comparison with SwiftData -Learn how SharingGRDB compares to SwiftData when solving a variety of problems. +Learn how SQLiteData compares to SwiftData when solving a variety of problems. ## Overview -The SharingGRDB library can replace SwiftData for many kinds of apps, and provide additional +The SQLiteData library can replace SwiftData for many kinds of apps, and provide additional benefits such as direct access to the underlying SQLite schema, and better integration outside of SwiftUI views (including UIKit, `@Observable` models, _etc._). This article describes how the two approaches compare in a variety of situations, such as setting up the data store, fetching data, @@ -26,8 +26,8 @@ associations, and more. ### Defining your schema -Both SharingGRDB and SwiftData come with tools to expose your data types' fields to the compiler -so that type-safe and schema-safe queries can be written. SharingGRDB uses another library of ours +Both SQLiteData and SwiftData come with tools to expose your data types' fields to the compiler +so that type-safe and schema-safe queries can be written. SQLiteData uses another library of ours to provide these tools, called [StructuredQueries][sq-gh], and its `@Table` macro works similarly to SwiftData's `@Model` macro: @@ -36,7 +36,7 @@ to SwiftData's `@Model` macro: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Table struct Item { let id: Int @@ -82,15 +82,15 @@ to define your schema. ### Setting up external storage -Both SharingGRDB and SwiftData require some work to be done at the entry point of the app in order -to set up the external storage system that will be used throughout the app. In SharingGRDB we use +Both SQLiteData and SwiftData require some work to be done at the entry point of the app in order +to set up the external storage system that will be used throughout the app. In SQLiteData we use the `prepareDependencies` function to set up the default database used, and in SwiftUI you construct a `ModelContainer` and propagate it through the environment: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @main struct MyApp: App { init() { @@ -126,17 +126,17 @@ a `ModelContainer` and propagate it through the environment: } See for more advice on the various ways you will want to create and -configure your SQLite database for use with SharingGRDB. +configure your SQLite database for use with SQLiteData. ### Fetching data for a view -To fetch data from a SQLite database you use the `@FetchAll` property wrapper in SharingGRDB, +To fetch data from a SQLite database you use the `@FetchAll` property wrapper in SQLiteData, whereas you use the `@Query` macro with SwiftData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData struct ItemsView: View { @FetchAll(Item.order(by: \.title)) var items @@ -199,7 +199,7 @@ its functionality from scratch: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Observable class FeatureModel { @ObservationIgnored @@ -261,7 +261,7 @@ search for rows in a table: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData struct ItemsView: View { @State var searchText = "" @FetchAll var items: [Item] @@ -348,7 +348,7 @@ For example, to get access to `defaultDatabase`, you use the `@Dependency` prope @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database ``` } @@ -360,12 +360,12 @@ For example, to get access to `defaultDatabase`, you use the `@Dependency` prope } } -Then, to create a new row in a table you use the `write` and `insert` methods from SharingGRDB: +Then, to create a new row in a table you use the `write` and `insert` methods from SQLiteData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database try database.write { db in @@ -386,12 +386,12 @@ Then, to create a new row in a table you use the `write` and `insert` methods fr } } -To update an existing row you can use the `write` and `update` methods from SharingGRDB: +To update an existing row you can use the `write` and `update` methods from SQLiteData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database existingItem.title = "Computer" @@ -411,12 +411,12 @@ To update an existing row you can use the `write` and `update` methods from Shar } } -And to delete an existing row, you can use the `write` and `delete` methods from SharingGRDB: +And to delete an existing row, you can use the `write` and `delete` methods from SQLiteData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database try database.write { db in @@ -437,8 +437,8 @@ And to delete an existing row, you can use the `write` and `delete` methods from ### Associations -The biggest difference between SwiftData and SharingGRDB is that SwiftData provides tools for an -Object Relational Mapping (ORM), whereas SharingGRDB is largely just a nice API for interacting with SQLite +The biggest difference between SwiftData and SQLiteData is that SwiftData provides tools for an +Object Relational Mapping (ORM), whereas SQLiteData is largely just a nice API for interacting with SQLite directly. For example, SwiftData allows you to model a `Sport` type that belongs to many `Team`s like @@ -473,7 +473,7 @@ get all sports, and then executing a query for each sport to get the number of t sport. And on top of that, we are loading every team into memory just to compute the number of teams. We don't actually need any data from the team, only their aggregate count. -SharingGRDB does not provide these kinds of tools, and for good reason. Instead, if you know you +SQLiteData does not provide these kinds of tools, and for good reason. Instead, if you know you want to fetch all of the teams with their corresponding sport, you can simply perform a single query that joins the two tables together: @@ -564,7 +564,7 @@ var highPriorityReminders: [Reminder] This will now work, but of course these fields can now hold over 9 quintillion possible values when only a few values are valid. -On the other hand, booleans and enums work just fine in SharingGRDB: +On the other hand, booleans and enums work just fine in SQLiteData: ```swift @Table @@ -607,7 +607,7 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Table struct Item { let id: Int @@ -727,7 +727,7 @@ structure of your data types. The overall steps to follow are as such: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData migrator.registerMigration("Make 'title' unique") { db in // 1️⃣ Delete all items that have duplicate title, keeping the first created one: try Item @@ -863,20 +863,20 @@ other way around. ### CloudKit -Both SharingGRDB and SwiftData support basic synchronization of models to CloudKit so that data -can be made available on all of a user's devices. However, SharingGRDB also supports sharing records +Both SQLiteData and SwiftData support basic synchronization of models to CloudKit so that data +can be made available on all of a user's devices. However, SQLiteData also supports sharing records with other iCloud users, and it exposes the underlying CloudKit data types (e.g. `CKRecord`) so that you can interact directly with CloudKit if needed. -Setting up a database and sync engine in SharingGRDB isn't much different from setting up a +Setting up a database and sync engine in SQLiteData isn't much different from setting up a SwiftData stack with CloudKit. The main difference is that one must explicitly provide the -container identifier in SharingGRDB because SwiftData has been privileged in being able to +container identifier in SQLiteData because SwiftData has been privileged in being able to inspect the Entitlements.plist in order to automatically extract that information: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @main struct MyApp: App { init() { @@ -926,7 +926,7 @@ SwiftData also has a few limitations in what features you are allowed to use in * All properties on a model must be optional or have a default value. * All relationships must be optional. -SharingGRDB has only one of these limitations: +SQLiteData has only one of these limitations: * Unique constraints on columns (except for the primary key) cannot be upheld on a distributed schema. For example, if you have a `Tag` table with a unique `title` column, then what @@ -947,6 +947,6 @@ information about CloudKit synchronization, see . SwiftData and the `@Query` macro require iOS 17, macOS 14, tvOS 17, watchOS 10 and higher, and some newer features require even more recent versions of iOS. -Meanwhile, SharingGRDB has a broad set of deployment targets supporting all the way back to iOS 13, +Meanwhile, SQLiteData has a broad set of deployment targets supporting all the way back to iOS 13, macOS 10.15, tvOS 13, and watchOS 6. This means you can use these tools on essentially any application today with no restrictions. diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/DynamicQueries.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/DynamicQueries.md index 84322a82..57cb4154 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Articles/DynamicQueries.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Articles/DynamicQueries.md @@ -44,7 +44,7 @@ every single time `displayedItems` is evaluated, which will be at least once for view's body is computed, but could also be more. This kind of data processing is exactly what SQLite excels at, and so we can offload this work by -modifying the query itself. One can do this with SharingGRDB by using the `load` method on +modifying the query itself. One can do this with SQLiteData by using the `load` method on ``FetchAll``, ``FetchOne`` or ``Fetch`` in order to load a new key, and hence execute a new query: ```swift diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides.md index 38e793af..87e8925e 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides.md @@ -1,10 +1,10 @@ # Migration guides -Learn how to upgrade your application to the newest version of SharingGRDB. +Learn how to upgrade your application to the newest version of SQLiteData. ## Overview -SharingGRDB is under constant development, and we are always looking for ways to +SQLiteData is under constant development, and we are always looking for ways to simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs in favor of newer ones. We recommend people update their code as quickly as possible to the newest APIs, and these guides contain tips to do so. diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md index 9495bcbc..b3b3bf6d 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md @@ -4,7 +4,7 @@ Update your code to make use of powerful new querying capabilities. ## Overview -SharingGRDB is under constant development, and we are always looking for ways to +SQLiteData is under constant development, and we are always looking for ways to simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs in favor of newer ones. We recommend people update their code as quickly as possible to the newest APIs, and these guides contain tips to do so. @@ -15,7 +15,7 @@ APIs, and these guides contain tips to do so. ## @FetchAll, @FetchOne, @Fetch -SharingGRDB 0.2.0 comes with 3 brand new property wrappers that largely replace the need for +SQLiteData 0.2.0 comes with 3 brand new property wrappers that largely replace the need for SwiftData and its `@Query` macro. In 0.1.0, one would perform queries as either a hard coded SQL string: @@ -39,7 +39,7 @@ struct CompletedReminders: FetchKeyRequest { var completedReminders ``` -Each of these are cumbersome, and version 0.2.0 of SharingGRDB fixes things thanks to our newly +Each of these are cumbersome, and version 0.2.0 of SQLiteData fixes things thanks to our newly released [StructuredQueries][] library. You can now describe the query for your data in a type-safe manner, and directly inline: @@ -58,13 +58,13 @@ The [`.fetchAll`](), and [`.fetch`]() APIs have been soft-deprecated in favor of the more modern tools described above and in . They will be hard -deprecated in a future release of SharingGRDB, and removed in 1.0. +deprecated in a future release of SQLiteData, and removed in 1.0. ## Avoiding the cost of macros -SharingGRDB introduces a macro in version 0.2.0 (in particular, the `@Table` macro), and +SQLiteData introduces a macro in version 0.2.0 (in particular, the `@Table` macro), and unfortunately macros currently come with an unfortunate cost in that you have to compile SwiftSyntax from scratch, which can take time. If the cost of macros is too high for you, then you can depend -on the SharingGRDBCore module instead of the full SharingGRDB module. This will give you access to -only a subset of tools provided by SharingGRDB, but you will have access to all tools that were +on the SQLiteDataCore module instead of the full SQLiteData module. This will give you access to +only a subset of tools provided by SQLiteData, but you will have access to all tools that were available in version 0.1.0 of the library. diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/Observing.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/Observing.md index 0f70c837..d0bc43a3 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Articles/Observing.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Articles/Observing.md @@ -54,7 +54,7 @@ struct ItemsView: View { ``` > Note: Due to how macros work in Swift, property wrappers must be annotated with -> `@ObservationIgnored`, but this does not affect observation as SharingGRDB handles its own +> `@ObservationIgnored`, but this does not affect observation as SQLiteData handles its own > observation. ### UIKit diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SQLiteDataCore/Documentation.docc/Articles/PreparingDatabase.md index 895e18a0..3b447f3f 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Articles/PreparingDatabase.md @@ -89,7 +89,7 @@ when running your app in the simulator/device and using `Swift.print` in preview > is surrounded in `#if DEBUG`, but it is something to be careful of in your own apps. > Tip: `@Dependency(\.context)` comes from the [Swift Dependencies][swift-dependencies-gh] library, -> which SharingGRDB uses to share its database connection across fetch keys. It allows you to +> which SQLiteData uses to share its database connection across fetch keys. It allows you to > inspect the context your app is running in: live, preview or test. [swift-dependencies-gh]: https://github.com/pointfreeco/swift-dependencies diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/Fetch.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/Fetch.md index d6a51b81..77139bb2 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Extensions/Fetch.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Extensions/Fetch.md @@ -1,4 +1,4 @@ -# ``SharingGRDBCore/Fetch`` +# ``SQLiteDataCore/Fetch`` ## Overview diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchAll.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchAll.md index 457b6e99..865531db 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchAll.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchAll.md @@ -1,4 +1,4 @@ -# ``SharingGRDBCore/FetchAll`` +# ``SQLiteDataCore/FetchAll`` ## Overview diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKey.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKey.md index 49ed8509..cd7e7803 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKey.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKey.md @@ -1,4 +1,4 @@ -# ``SharingGRDBCore/FetchKey`` +# ``SQLiteDataCore/FetchKey`` ## Topics diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKeyRequest.md index 501238a2..2819ccf0 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKeyRequest.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKeyRequest.md @@ -1,4 +1,4 @@ -# ``SharingGRDBCore/FetchKeyRequest`` +# ``SQLiteDataCore/FetchKeyRequest`` ## Topics diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchOne.md b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchOne.md index d001456e..943466f0 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchOne.md +++ b/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchOne.md @@ -1,4 +1,4 @@ -# ``SharingGRDBCore/FetchOne`` +# ``SQLiteDataCore/FetchOne`` ## Overview diff --git a/Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md b/Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md index 47a04e30..cc0afe46 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md @@ -1,4 +1,4 @@ -# ``SharingGRDBCore`` +# ``SQLiteDataCore`` A fast, lightweight replacement for SwiftData, powered by SQL and supporting CloudKit synchronization. @@ -7,13 +7,13 @@ synchronization. > Important: This module is automatically imported when you `import SQLiteData`. -SharingGRDB is a [fast](#Performance), lightweight replacement for SwiftData that deploys all the +SQLiteData is a [fast](#Performance), lightweight replacement for SwiftData that deploys all the way back to the iOS 13 generation of targets. @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @FetchAll var items: [Item] @@ -53,15 +53,15 @@ way back to the iOS 13 generation of targets. Both of the above examples fetch items from an external data store using Swift data types, and both are automatically observed by SwiftUI so that views are recomputed when the external data changes, -but SharingGRDB is powered directly by SQLite using [Sharing](#What-is-Sharing), +but SQLiteData is powered directly by SQLite using [Sharing](#What-is-Sharing), [StructuredQueries](#What-is-StructuredQueries), and [GRDB](#What-is-GRDB), and is usable from anywhere, including UIKit, `@Observable` models, and more. -> Note: For more information on SharingGRDB's querying capabilities, see . +> Note: For more information on SQLiteData's querying capabilities, see . ## Quick start -Before SharingGRDB's property wrappers can fetch data from SQLite, you need to provide---at +Before SQLiteData's property wrappers can fetch data from SQLite, you need to provide---at runtime---the default database it should use. This is typically done as early as possible in your app's lifetime, like the app entry point in SwiftUI, and is analogous to configuring model storage in SwiftData: @@ -69,7 +69,7 @@ in SwiftData: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @main struct MyApp: App { init() { @@ -106,8 +106,8 @@ in SwiftData: > Note: For more information on preparing a SQLite database, see . -This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like -[`@FetchAll`](), which are similar to SwiftData's +This `defaultDatabase` connection is used implicitly by SQLiteData's strategies, like +[`@FetchAll`](), which are similar to SwiftData's `@Query` macro, but more powerful: @Row { @@ -150,7 +150,7 @@ a model context, via a property wrapper: @Row { @Column { ```swift - // SharingGRDB + // SQLiteData @Dependency(\.defaultDatabase) var database try database.write { db in @@ -171,7 +171,7 @@ a model context, via a property wrapper: } } -> Note: For more information on how SharingGRDB compares to SwiftData, see +> Note: For more information on how SQLiteData compares to SwiftData, see > . Further, if you want to synchronize the local database to CloudKit so that it is available on @@ -199,12 +199,12 @@ struct MyApp: App { [CloudKit Synchronization] -This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read +This is all you need to know to get started with SQLiteData, but there's much more to learn. Read the [articles](#Essentials) below to learn how to best utilize this library. ## Performance -SharingGRDB leverages high-performance decoding from +SQLiteData leverages high-performance decoding from [StructuredQueries](https://github.com/pointfreeco/swift-structured-queries) to turn fetched data into your Swift domain types, and has a performance profile similar to invoking SQLite's C APIs directly. @@ -217,7 +217,7 @@ taste of how it compares: Orders.fetchAll setup rampup duration SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 Lighter (1.4.10) 0 0.164 8.059 - SharingGRDB (0.2.0) 0 0.172 8.511 + SQLiteData (0.2.0) 0 0.172 8.511 GRDB (7.4.1, manual decoding) 0 0.376 18.819 SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 SQLite.swift (0.15.3, Codable) 0 0.863 43.261 @@ -246,7 +246,7 @@ sharing your app's model data across features and with external systems, such as the file system, and more. This library builds upon the tools from Sharing in order to allow for the [fetching]() and [observing]() of data in a SQLite database. -This is all you need to know about Sharing to hit the ground running with SharingGRDB, but it only +This is all you need to know about Sharing to hit the ground running with SQLiteData, but it only scratches the surface of what the library makes possible. It can also act as a replacement to SwiftUI's `@AppStorage` that works with UIKit and `@Observable` models, and can be integrated with custom persistence strategies. To learn more, check out @@ -259,7 +259,7 @@ building SQL in a safe, expressive, and composable manner, and decoding results performance. Learn more about designing schemas and building queries with the library by seeing its [documentation](https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore/). -SharingGRDB contains an official StructuredQueries driver that connects it to SQLite _via_ GRDB, +SQLiteData contains an official StructuredQueries driver that connects it to SQLite _via_ GRDB, though its query builder and decoder are general purpose tools that can interface with other databases (MySQL, Postgres, _etc._) and database libraries. @@ -275,7 +275,7 @@ If you're already familiar with SQLite, GRDB provides thin APIs that can be leve in short order. If you're new to SQLite, GRDB offers a great introduction to a highly portable database engine. We recommend ([as does GRDB](https://github.com/groue/GRDB.swift?tab=readme-ov-file#documentation)) a familiarity -with SQLite to take full advantage of GRDB and SharingGRDB. +with SQLite to take full advantage of GRDB and SQLiteData. ## Topics diff --git a/Sources/SQLiteDataCore/Traits/Tagged.swift b/Sources/SQLiteDataCore/Traits/Tagged.swift index 3dd6db46..c7fe36bb 100644 --- a/Sources/SQLiteDataCore/Traits/Tagged.swift +++ b/Sources/SQLiteDataCore/Traits/Tagged.swift @@ -1,4 +1,4 @@ -#if SharingGRDBTagged +#if SQLiteDataTagged import Tagged extension Tagged: IdentifierStringConvertible where RawValue: IdentifierStringConvertible { diff --git a/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift b/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift index ded3f67a..72808f75 100644 --- a/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift +++ b/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift @@ -52,7 +52,7 @@ extension DependencyValues { case .live: return """ A blank, in-memory database is being used. To set the database that is used by \ - 'SharingGRDB', use the 'prepareDependencies' tool as early as possible in the lifetime \ + 'SQLiteData', use the 'prepareDependencies' tool as early as possible in the lifetime \ of your app, such as in your app or scene delegate in UIKit, or the app entry point in \ SwiftUI: @@ -70,7 +70,7 @@ extension DependencyValues { case .preview: return """ A blank, in-memory database is being used. To set the database that is used by \ - 'SharingGRDB' in a preview, use a tool like 'prepareDependencies': + 'SQLiteData' in a preview, use a tool like 'prepareDependencies': #Preview { let _ = prepareDependencies { @@ -83,7 +83,7 @@ extension DependencyValues { case .test: return """ A blank, in-memory database is being used. To set the database that is used by \ - 'SharingGRDB' in a test, use a tool like the 'dependency' trait from \ + 'SQLiteData' in a test, use a tool like the 'dependency' trait from \ 'DependenciesTestSupport': import DependenciesTestSupport @@ -109,6 +109,6 @@ extension DependencyValues { #if DEBUG extension String { - package static let defaultDatabaseLabel = "co.pointfree.SharingGRDB.testValue" + package static let defaultDatabaseLabel = "co.pointfree.SQLiteData.testValue" } #endif diff --git a/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md b/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md index 3f444f66..0634ec99 100644 --- a/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md +++ b/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md @@ -8,7 +8,7 @@ imported when you `import SQLiteData` or `StructuredQueriesGRDB`. This library can be used to directly execute queries built using the [StructuredQueries][sq-gh] library and a [GRDB][grdb-gh] database. -While the `SharingGRDB` module provides tools to observe queries using the `@FetchAll`, `@FetchOne`, +While the `SQLiteData` module provides tools to observe queries using the `@FetchAll`, `@FetchOne`, and `@Fetch` property wrappers, you will also want to execute one-off queries directly, especially when it comes to `INSERT`, `UPDATE`, and `DELETE` statements. This module extends StructuredQueries' `Statement` type with `execute`, `fetchAll`, `fetchOne`, and `fetchCount` methods diff --git a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift b/Sources/StructuredQueriesGRDBCore/QueryCursor.swift index 41876241..0016cf16 100644 --- a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift +++ b/Sources/StructuredQueriesGRDBCore/QueryCursor.swift @@ -55,7 +55,7 @@ final class QueryValueCursor: QueryCursor { typealias Element = () // NB: Required to workaround a "Legacy previews execution" bug - // https://github.com/pointfreeco/sharing-grdb/pull/60 + // https://github.com/pointfreeco/sqlite-data/pull/60 @usableFromInline override init(db: Database, query: QueryFragment) throws { try super.init(db: db, query: query) diff --git a/Tests/SharingGRDB.xctestplan b/Tests/SharingGRDB.xctestplan index 74b76fb1..898099c8 100644 --- a/Tests/SharingGRDB.xctestplan +++ b/Tests/SharingGRDB.xctestplan @@ -15,15 +15,15 @@ { "target" : { "containerPath" : "container:", - "identifier" : "SharingGRDBTests", - "name" : "SharingGRDBTests" + "identifier" : "StructuredQueriesGRDBTests", + "name" : "StructuredQueriesGRDBTests" } }, { "target" : { "containerPath" : "container:", - "identifier" : "StructuredQueriesGRDBTests", - "name" : "StructuredQueriesGRDBTests" + "identifier" : "SQLiteDataTests", + "name" : "SQLiteDataTests" } } ], From 69ac98d65a0a9227cf69d4114a981cc0fd0dc0eb Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 2 Sep 2025 17:09:47 -0500 Subject: [PATCH 512/581] wip --- .../CloudKitTests/FetchRecordZoneChangesTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index b414f90d..64970810 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -4,7 +4,7 @@ import Foundation import InlineSnapshotTesting import OrderedCollections import SQLiteData -import SharingGRDBTestSupport +import SQLiteDataTestSupport import SnapshotTestingCustomDump import Testing From ebf5076be42848d4415a9604b86fff3a23638e71 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 15:35:55 -0700 Subject: [PATCH 513/581] flatten --- Package.swift | 60 ++------------- Package@swift-6.0.swift | 65 ++++------------ .../CloudKit/CloudContainer.swift | 0 .../CloudKit/CloudDatabase.swift | 0 .../CloudKit/CloudKit+StructuredQueries.swift | 0 .../CloudKit/CloudKitSharing.swift | 0 .../CloudKit/DefaultSyncEngine.swift | 0 .../CloudKit/ForeignKey.swift | 0 .../IdentifierStringConvertible.swift | 0 .../CloudKit/Internal/DatetimeGenerator.swift | 0 .../CloudKit/Internal/IsolatedWeakVar.swift | 0 .../Internal/MockCloudContainer.swift | 0 .../CloudKit/Internal/MockCloudDatabase.swift | 0 .../CloudKit/Internal/MockSyncEngine.swift | 0 .../CloudKit/Logging.swift | 0 .../CloudKit/Metadatabase.swift | 0 ...ndingRecordZoneChange+MacroExpansion.swift | 0 .../CloudKit/PendingRecordZoneChange.swift | 0 .../CloudKit/RecordType+MacroExpansion.swift | 0 .../CloudKit/RecordType.swift | 0 .../CloudKit/SQLiteSchema.swift | 0 .../StateSerialization+MacroExpansion.swift | 0 .../CloudKit/StateSerialization.swift | 0 .../CloudKit/SyncEngine.Event.swift | 0 .../CloudKit/SyncEngine.swift | 2 +- .../CloudKit/SyncEngineProtocol+Live.swift | 0 .../CloudKit/SyncEngineProtocol.swift | 0 .../SyncMetadata+MacroExpansion.swift | 0 .../CloudKit/SyncMetadata.swift | 0 .../CloudKit/TableInfo.swift | 0 .../CloudKit/Triggers.swift | 0 .../UnsyncedRecordID+MacroExpansion.swift | 0 .../CloudKit/UnsyncedRecordID.swift | 0 .../Documentation.docc/Articles/CloudKit.md | 0 .../Articles/CloudKitSharing.md | 0 .../Articles/ComparisonWithSwiftData.md | 0 .../Articles/Deprecations.md | 0 .../Articles/DynamicQueries.md | 0 .../Documentation.docc/Articles/Fetching.md | 0 .../Articles/MigrationGuides.md | 0 .../MigrationGuides/MigratingTo0.2.md | 0 .../Documentation.docc/Articles/Observing.md | 0 .../Articles/PreparingDatabase.md | 0 .../Documentation.docc/Extensions/Fetch.md | 0 .../Documentation.docc/Extensions/FetchAll.md | 0 .../Extensions/FetchCursor.md | 7 ++ .../Documentation.docc/Extensions/FetchKey.md | 0 .../Extensions/FetchKeyRequest.md | 0 .../Documentation.docc/Extensions/FetchOne.md | 0 .../sync-diagram-many-to-many-refactor.png | Bin .../Resources/sync-diagram-many-to-many.png | Bin ...sync-diagram-one-to-at-most-one-unique.png | Bin .../Resources/sync-diagram-one-to-many.png | Bin .../Resources/sync-diagram-root-record.png | Bin .../Documentation.docc/SQLiteData.md} | 55 +++++++------- .../Documentation.docc/SharingGRDB.md | 44 ----------- Sources/SQLiteData/Exports.swift | 15 +++- .../Internal/DataManager.swift | 0 .../Internal/Deprecations.swift | 0 .../Internal/ISO8601.swift | 0 .../Internal/StatementKey.swift | 0 .../Internal/UserDatabase.swift | 0 .../SQLiteDataCore/Fetch.swift | 0 .../SQLiteDataCore/FetchAll.swift | 0 .../SQLiteDataCore/FetchKey+SwiftUI.swift | 0 .../SQLiteDataCore/FetchKey.swift | 1 - .../SQLiteDataCore/FetchKeyRequest.swift | 0 .../SQLiteDataCore/FetchOne.swift | 0 .../CustomFunctions.swift | 0 .../DefaultDatabase.swift | 0 .../StructuredQueries+GRDB}/QueryCursor.swift | 0 .../SQLiteQueryDecoder.swift | 0 .../StructuredQueries+GRDB}/Seed.swift | 0 .../Statement+GRDB.swift | 0 .../Traits/Tagged.swift | 0 Sources/SQLiteDataCore/Internal/Exports.swift | 13 ---- .../SQLiteDataTestSupport/AssertQuery.swift | 2 +- .../StructuredQueriesGRDB.md | 10 --- .../StructuredQueriesGRDB.swift | 3 - .../StructuredQueriesGRDBCore.md | 70 ------------------ .../Internal/Exports.swift | 2 - .../CustomFunctionTests.swift | 2 +- .../Internal/CloudKitTestHelpers.swift | 2 +- .../Internal/UserDatabaseHelpers.swift | 2 +- .../MigrationTests.swift | 2 +- .../QueryCursorTests.swift | 2 +- Tests/SharingGRDB.xctestplan | 31 -------- 87 files changed, 73 insertions(+), 317 deletions(-) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/CloudContainer.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/CloudDatabase.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/CloudKit+StructuredQueries.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/CloudKitSharing.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/DefaultSyncEngine.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/ForeignKey.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/IdentifierStringConvertible.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/Internal/DatetimeGenerator.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/Internal/IsolatedWeakVar.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/Internal/MockCloudContainer.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/Internal/MockCloudDatabase.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/Internal/MockSyncEngine.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/Logging.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/Metadatabase.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/PendingRecordZoneChange+MacroExpansion.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/PendingRecordZoneChange.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/RecordType+MacroExpansion.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/RecordType.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/SQLiteSchema.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/StateSerialization+MacroExpansion.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/StateSerialization.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/SyncEngine.Event.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/SyncEngine.swift (99%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/SyncEngineProtocol+Live.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/SyncEngineProtocol.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/SyncMetadata+MacroExpansion.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/SyncMetadata.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/TableInfo.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/Triggers.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/UnsyncedRecordID+MacroExpansion.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/CloudKit/UnsyncedRecordID.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/CloudKit.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/CloudKitSharing.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/ComparisonWithSwiftData.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/Deprecations.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/DynamicQueries.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/Fetching.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/MigrationGuides.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/Observing.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Articles/PreparingDatabase.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Extensions/Fetch.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Extensions/FetchAll.md (100%) create mode 100644 Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Extensions/FetchKey.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Extensions/FetchKeyRequest.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Extensions/FetchOne.md (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Resources/sync-diagram-many-to-many.png (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Resources/sync-diagram-one-to-many.png (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Documentation.docc/Resources/sync-diagram-root-record.png (100%) rename Sources/{SQLiteDataCore/Documentation.docc/SharingGRDBCore.md => SQLiteData/Documentation.docc/SQLiteData.md} (81%) delete mode 100644 Sources/SQLiteData/Documentation.docc/SharingGRDB.md rename Sources/{SQLiteDataCore => SQLiteData}/Internal/DataManager.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Internal/Deprecations.swift (100%) rename Sources/{StructuredQueriesGRDBCore => SQLiteData}/Internal/ISO8601.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Internal/StatementKey.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Internal/UserDatabase.swift (100%) rename Sources/{ => SQLiteData}/SQLiteDataCore/Fetch.swift (100%) rename Sources/{ => SQLiteData}/SQLiteDataCore/FetchAll.swift (100%) rename Sources/{ => SQLiteData}/SQLiteDataCore/FetchKey+SwiftUI.swift (100%) rename Sources/{ => SQLiteData}/SQLiteDataCore/FetchKey.swift (99%) rename Sources/{ => SQLiteData}/SQLiteDataCore/FetchKeyRequest.swift (100%) rename Sources/{ => SQLiteData}/SQLiteDataCore/FetchOne.swift (100%) rename Sources/{StructuredQueriesGRDBCore => SQLiteData/StructuredQueries+GRDB}/CustomFunctions.swift (100%) rename Sources/{StructuredQueriesGRDBCore => SQLiteData/StructuredQueries+GRDB}/DefaultDatabase.swift (100%) rename Sources/{StructuredQueriesGRDBCore => SQLiteData/StructuredQueries+GRDB}/QueryCursor.swift (100%) rename Sources/{StructuredQueriesGRDBCore => SQLiteData/StructuredQueries+GRDB}/SQLiteQueryDecoder.swift (100%) rename Sources/{StructuredQueriesGRDBCore => SQLiteData/StructuredQueries+GRDB}/Seed.swift (100%) rename Sources/{StructuredQueriesGRDBCore => SQLiteData/StructuredQueries+GRDB}/Statement+GRDB.swift (100%) rename Sources/{SQLiteDataCore => SQLiteData}/Traits/Tagged.swift (100%) delete mode 100644 Sources/SQLiteDataCore/Internal/Exports.swift delete mode 100644 Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md delete mode 100644 Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift delete mode 100644 Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md delete mode 100644 Sources/StructuredQueriesGRDBCore/Internal/Exports.swift rename Tests/{StructuredQueriesGRDBTests => SQLiteDataTests}/CustomFunctionTests.swift (96%) rename Tests/{StructuredQueriesGRDBTests => SQLiteDataTests}/MigrationTests.swift (97%) rename Tests/{StructuredQueriesGRDBTests => SQLiteDataTests}/QueryCursorTests.swift (96%) delete mode 100644 Tests/SharingGRDB.xctestplan diff --git a/Package.swift b/Package.swift index bc9b5291..c721f053 100644 --- a/Package.swift +++ b/Package.swift @@ -15,22 +15,10 @@ let package = Package( name: "SQLiteData", targets: ["SQLiteData"] ), - .library( - name: "SQLiteDataCore", - targets: ["SQLiteDataCore"] - ), .library( name: "SQLiteDataTestSupport", targets: ["SQLiteDataTestSupport"] ), - .library( - name: "StructuredQueriesGRDB", - targets: ["StructuredQueriesGRDB"] - ), - .library( - name: "StructuredQueriesGRDBCore", - targets: ["StructuredQueriesGRDBCore"] - ), ], traits: [ .trait( @@ -60,18 +48,12 @@ let package = Package( .target( name: "SQLiteData", dependencies: [ - "SQLiteDataCore", - "StructuredQueriesGRDB", - ] - ), - .target( - name: "SQLiteDataCore", - dependencies: [ - "StructuredQueriesGRDBCore", + .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "Sharing", package: "swift-sharing"), - .product(name: "StructuredQueriesCore", package: "swift-structured-queries"), + .product(name: "StructuredQueriesSQLite", package: "swift-structured-queries"), .product( name: "Tagged", package: "swift-tagged", @@ -79,17 +61,6 @@ let package = Package( ), ] ), - .testTarget( - name: "SQLiteDataTests", - dependencies: [ - "SQLiteData", - "SQLiteDataTestSupport", - .product(name: "DependenciesTestSupport", package: "swift-dependencies"), - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), - .product(name: "StructuredQueries", package: "swift-structured-queries"), - ] - ), .target( name: "SQLiteDataTestSupport", dependencies: [ @@ -99,29 +70,14 @@ let package = Package( .product(name: "StructuredQueriesTestSupport", package: "swift-structured-queries"), ] ), - .target( - name: "StructuredQueriesGRDBCore", - dependencies: [ - .product(name: "GRDB", package: "GRDB.swift"), - .product(name: "Dependencies", package: "swift-dependencies"), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), - .product(name: "StructuredQueriesCore", package: "swift-structured-queries"), - .product(name: "StructuredQueriesSQLiteCore", package: "swift-structured-queries"), - ] - ), - .target( - name: "StructuredQueriesGRDB", - dependencies: [ - "StructuredQueriesGRDBCore", - .product(name: "StructuredQueries", package: "swift-structured-queries"), - .product(name: "StructuredQueriesSQLite", package: "swift-structured-queries"), - ] - ), .testTarget( - name: "StructuredQueriesGRDBTests", + name: "SQLiteDataTests", dependencies: [ - "StructuredQueriesGRDB", + "SQLiteData", + "SQLiteDataTestSupport", .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), .product(name: "StructuredQueries", package: "swift-structured-queries"), ] ), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 0d31a75b..ccd04032 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 import PackageDescription @@ -15,55 +15,31 @@ let package = Package( name: "SQLiteData", targets: ["SQLiteData"] ), - .library( - name: "SQLiteDataCore", - targets: ["SQLiteDataCore"] - ), .library( name: "SQLiteDataTestSupport", targets: ["SQLiteDataTestSupport"] ), - .library( - name: "StructuredQueriesGRDB", - targets: ["StructuredQueriesGRDB"] - ), - .library( - name: "StructuredQueriesGRDBCore", - targets: ["StructuredQueriesGRDBCore"] - ), ], dependencies: [ + .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), + .package(url: "https://github.com/groue/GRDB.swift", from: "7.6.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), - .package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.16.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ .target( name: "SQLiteData", dependencies: [ - "SQLiteDataCore", - "StructuredQueriesGRDB", - ] - ), - .target( - name: "SQLiteDataCore", - dependencies: [ - "StructuredQueriesGRDBCore", + .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "Sharing", package: "swift-sharing"), - ] - ), - .testTarget( - name: "SQLiteDataTests", - dependencies: [ - "SQLiteData", - "SQLiteDataTestSupport", - .product(name: "DependenciesTestSupport", package: "swift-dependencies"), - .product(name: "StructuredQueries", package: "swift-structured-queries"), + .product(name: "StructuredQueriesSQLite", package: "swift-structured-queries"), ] ), .target( @@ -75,29 +51,14 @@ let package = Package( .product(name: "StructuredQueriesTestSupport", package: "swift-structured-queries"), ] ), - .target( - name: "StructuredQueriesGRDBCore", - dependencies: [ - .product(name: "GRDB", package: "GRDB.swift"), - .product(name: "Dependencies", package: "swift-dependencies"), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), - .product(name: "StructuredQueriesCore", package: "swift-structured-queries"), - .product(name: "StructuredQueriesSQLiteCore", package: "swift-structured-queries"), - ] - ), - .target( - name: "StructuredQueriesGRDB", - dependencies: [ - "StructuredQueriesGRDBCore", - .product(name: "StructuredQueries", package: "swift-structured-queries"), - .product(name: "StructuredQueriesSQLite", package: "swift-structured-queries"), - ] - ), .testTarget( - name: "StructuredQueriesGRDBTests", + name: "SQLiteDataTests", dependencies: [ - "StructuredQueriesGRDB", + "SQLiteData", + "SQLiteDataTestSupport", .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), .product(name: "StructuredQueries", package: "swift-structured-queries"), ] ), diff --git a/Sources/SQLiteDataCore/CloudKit/CloudContainer.swift b/Sources/SQLiteData/CloudKit/CloudContainer.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/CloudContainer.swift rename to Sources/SQLiteData/CloudKit/CloudContainer.swift diff --git a/Sources/SQLiteDataCore/CloudKit/CloudDatabase.swift b/Sources/SQLiteData/CloudKit/CloudDatabase.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/CloudDatabase.swift rename to Sources/SQLiteData/CloudKit/CloudDatabase.swift diff --git a/Sources/SQLiteDataCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/CloudKit+StructuredQueries.swift rename to Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift diff --git a/Sources/SQLiteDataCore/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/CloudKitSharing.swift rename to Sources/SQLiteData/CloudKit/CloudKitSharing.swift diff --git a/Sources/SQLiteDataCore/CloudKit/DefaultSyncEngine.swift b/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/DefaultSyncEngine.swift rename to Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift diff --git a/Sources/SQLiteDataCore/CloudKit/ForeignKey.swift b/Sources/SQLiteData/CloudKit/ForeignKey.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/ForeignKey.swift rename to Sources/SQLiteData/CloudKit/ForeignKey.swift diff --git a/Sources/SQLiteDataCore/CloudKit/IdentifierStringConvertible.swift b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/IdentifierStringConvertible.swift rename to Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift diff --git a/Sources/SQLiteDataCore/CloudKit/Internal/DatetimeGenerator.swift b/Sources/SQLiteData/CloudKit/Internal/DatetimeGenerator.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/Internal/DatetimeGenerator.swift rename to Sources/SQLiteData/CloudKit/Internal/DatetimeGenerator.swift diff --git a/Sources/SQLiteDataCore/CloudKit/Internal/IsolatedWeakVar.swift b/Sources/SQLiteData/CloudKit/Internal/IsolatedWeakVar.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/Internal/IsolatedWeakVar.swift rename to Sources/SQLiteData/CloudKit/Internal/IsolatedWeakVar.swift diff --git a/Sources/SQLiteDataCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/Internal/MockCloudContainer.swift rename to Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift diff --git a/Sources/SQLiteDataCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/Internal/MockCloudDatabase.swift rename to Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift diff --git a/Sources/SQLiteDataCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/Internal/MockSyncEngine.swift rename to Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift diff --git a/Sources/SQLiteDataCore/CloudKit/Logging.swift b/Sources/SQLiteData/CloudKit/Logging.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/Logging.swift rename to Sources/SQLiteData/CloudKit/Logging.swift diff --git a/Sources/SQLiteDataCore/CloudKit/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Metadatabase.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/Metadatabase.swift rename to Sources/SQLiteData/CloudKit/Metadatabase.swift diff --git a/Sources/SQLiteDataCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/PendingRecordZoneChange+MacroExpansion.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/PendingRecordZoneChange+MacroExpansion.swift rename to Sources/SQLiteData/CloudKit/PendingRecordZoneChange+MacroExpansion.swift diff --git a/Sources/SQLiteDataCore/CloudKit/PendingRecordZoneChange.swift b/Sources/SQLiteData/CloudKit/PendingRecordZoneChange.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/PendingRecordZoneChange.swift rename to Sources/SQLiteData/CloudKit/PendingRecordZoneChange.swift diff --git a/Sources/SQLiteDataCore/CloudKit/RecordType+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/RecordType+MacroExpansion.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/RecordType+MacroExpansion.swift rename to Sources/SQLiteData/CloudKit/RecordType+MacroExpansion.swift diff --git a/Sources/SQLiteDataCore/CloudKit/RecordType.swift b/Sources/SQLiteData/CloudKit/RecordType.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/RecordType.swift rename to Sources/SQLiteData/CloudKit/RecordType.swift diff --git a/Sources/SQLiteDataCore/CloudKit/SQLiteSchema.swift b/Sources/SQLiteData/CloudKit/SQLiteSchema.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/SQLiteSchema.swift rename to Sources/SQLiteData/CloudKit/SQLiteSchema.swift diff --git a/Sources/SQLiteDataCore/CloudKit/StateSerialization+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/StateSerialization+MacroExpansion.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/StateSerialization+MacroExpansion.swift rename to Sources/SQLiteData/CloudKit/StateSerialization+MacroExpansion.swift diff --git a/Sources/SQLiteDataCore/CloudKit/StateSerialization.swift b/Sources/SQLiteData/CloudKit/StateSerialization.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/StateSerialization.swift rename to Sources/SQLiteData/CloudKit/StateSerialization.swift diff --git a/Sources/SQLiteDataCore/CloudKit/SyncEngine.Event.swift b/Sources/SQLiteData/CloudKit/SyncEngine.Event.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/SyncEngine.Event.swift rename to Sources/SQLiteData/CloudKit/SyncEngine.Event.swift diff --git a/Sources/SQLiteDataCore/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift similarity index 99% rename from Sources/SQLiteDataCore/CloudKit/SyncEngine.swift rename to Sources/SQLiteData/CloudKit/SyncEngine.swift index 21ec74ab..aed601df 100644 --- a/Sources/SQLiteDataCore/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -187,7 +187,7 @@ ) self.tablesByName = Dictionary(uniqueKeysWithValues: self.tables.map { ($0.tableName, $0) }) self.foreignKeysByTableName = foreignKeysByTableName - tablesByOrder = try SQLiteDataCore.tablesByOrder( + tablesByOrder = try SQLiteData.tablesByOrder( userDatabase: userDatabase, tables: allTables, tablesByName: tablesByName diff --git a/Sources/SQLiteDataCore/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SQLiteData/CloudKit/SyncEngineProtocol+Live.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/SyncEngineProtocol+Live.swift rename to Sources/SQLiteData/CloudKit/SyncEngineProtocol+Live.swift diff --git a/Sources/SQLiteDataCore/CloudKit/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/SyncEngineProtocol.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/SyncEngineProtocol.swift rename to Sources/SQLiteData/CloudKit/SyncEngineProtocol.swift diff --git a/Sources/SQLiteDataCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/SyncMetadata+MacroExpansion.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/SyncMetadata+MacroExpansion.swift rename to Sources/SQLiteData/CloudKit/SyncMetadata+MacroExpansion.swift diff --git a/Sources/SQLiteDataCore/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/SyncMetadata.swift rename to Sources/SQLiteData/CloudKit/SyncMetadata.swift diff --git a/Sources/SQLiteDataCore/CloudKit/TableInfo.swift b/Sources/SQLiteData/CloudKit/TableInfo.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/TableInfo.swift rename to Sources/SQLiteData/CloudKit/TableInfo.swift diff --git a/Sources/SQLiteDataCore/CloudKit/Triggers.swift b/Sources/SQLiteData/CloudKit/Triggers.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/Triggers.swift rename to Sources/SQLiteData/CloudKit/Triggers.swift diff --git a/Sources/SQLiteDataCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/UnsyncedRecordID+MacroExpansion.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/UnsyncedRecordID+MacroExpansion.swift rename to Sources/SQLiteData/CloudKit/UnsyncedRecordID+MacroExpansion.swift diff --git a/Sources/SQLiteDataCore/CloudKit/UnsyncedRecordID.swift b/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift similarity index 100% rename from Sources/SQLiteDataCore/CloudKit/UnsyncedRecordID.swift rename to Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKit.md rename to Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/CloudKitSharing.md rename to Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/ComparisonWithSwiftData.md rename to Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/Deprecations.md b/Sources/SQLiteData/Documentation.docc/Articles/Deprecations.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/Deprecations.md rename to Sources/SQLiteData/Documentation.docc/Articles/Deprecations.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/DynamicQueries.md b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/DynamicQueries.md rename to Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/Fetching.md rename to Sources/SQLiteData/Documentation.docc/Articles/Fetching.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides.md rename to Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md rename to Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/Observing.md b/Sources/SQLiteData/Documentation.docc/Articles/Observing.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/Observing.md rename to Sources/SQLiteData/Documentation.docc/Articles/Observing.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Articles/PreparingDatabase.md rename to Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/Fetch.md b/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Extensions/Fetch.md rename to Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchAll.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchAll.md rename to Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md new file mode 100644 index 00000000..1142cc2e --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchCursor.md @@ -0,0 +1,7 @@ +# ``StructuredQueriesCore/Statement/fetchCursor(_:)`` + +## Topics + +### Iterating over rows + +- ``QueryCursor`` diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKey.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKey.md rename to Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchKeyRequest.md rename to Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchOne.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Extensions/FetchOne.md rename to Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md diff --git a/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png rename to Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many-refactor.png diff --git a/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-many-to-many.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many.png similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-many-to-many.png rename to Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-many-to-many.png diff --git a/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png rename to Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-at-most-one-unique.png diff --git a/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-one-to-many.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-many.png similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-one-to-many.png rename to Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-one-to-many.png diff --git a/Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-root-record.png b/Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-root-record.png similarity index 100% rename from Sources/SQLiteDataCore/Documentation.docc/Resources/sync-diagram-root-record.png rename to Sources/SQLiteData/Documentation.docc/Resources/sync-diagram-root-record.png diff --git a/Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md similarity index 81% rename from Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md rename to Sources/SQLiteData/Documentation.docc/SQLiteData.md index cc0afe46..a2d1f38e 100644 --- a/Sources/SQLiteDataCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -1,14 +1,12 @@ -# ``SQLiteDataCore`` +# ``SQLiteData`` -A fast, lightweight replacement for SwiftData, powered by SQL and supporting CloudKit -synchronization. +A fast, lightweight replacement for SwiftData, powered by SQL. ## Overview -> Important: This module is automatically imported when you `import SQLiteData`. - -SQLiteData is a [fast](#Performance), lightweight replacement for SwiftData that deploys all the -way back to the iOS 13 generation of targets. +SQLiteData is a [fast](#Performance), lightweight replacement for SwiftData, including CloudKit +synchronization (and even CloudKit sharing), that deploys all the way back to the iOS 13 generation +of targets. @Row { @Column { @@ -53,9 +51,8 @@ way back to the iOS 13 generation of targets. Both of the above examples fetch items from an external data store using Swift data types, and both are automatically observed by SwiftUI so that views are recomputed when the external data changes, -but SQLiteData is powered directly by SQLite using [Sharing](#What-is-Sharing), -[StructuredQueries](#What-is-StructuredQueries), and [GRDB](#What-is-GRDB), and is usable from -anywhere, including UIKit, `@Observable` models, and more. +but SQLiteData is powered directly by SQLite and is usable from anywhere, including UIKit, +`@Observable` models, and more. > Note: For more information on SQLiteData's querying capabilities, see . @@ -106,9 +103,8 @@ in SwiftData: > Note: For more information on preparing a SQLite database, see . -This `defaultDatabase` connection is used implicitly by SQLiteData's strategies, like -[`@FetchAll`](), which are similar to SwiftData's -`@Query` macro, but more powerful: +This `defaultDatabase` connection is used implicitly by SQLiteData's property wrappers, like +``FetchAll``, which are similar to SwiftData's `@Query` macro, but more powerful: @Row { @Column { @@ -217,7 +213,7 @@ taste of how it compares: Orders.fetchAll setup rampup duration SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 Lighter (1.4.10) 0 0.164 8.059 - SQLiteData (0.2.0) 0 0.172 8.511 + SQLiteData (1.0.0) 0 0.172 8.511 GRDB (7.4.1, manual decoding) 0 0.376 18.819 SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 SQLite.swift (0.15.3, Codable) 0 0.863 43.261 @@ -239,19 +235,6 @@ for data and keep your views up-to-date when data in the database changes, and y either using its type-safe, discoverable query building APIs, or using its `#sql` macro for writing safe SQL strings. -## What is Sharing? - -[Sharing](https://github.com/pointfreeco/swift-sharing) is a universal and extensible solution for -sharing your app's model data across features and with external systems, such as user defaults, -the file system, and more. This library builds upon the tools from Sharing in order to allow for -the [fetching]() and [observing]() of data in a SQLite database. - -This is all you need to know about Sharing to hit the ground running with SQLiteData, but it only -scratches the surface of what the library makes possible. It can also act as a replacement to -SwiftUI's `@AppStorage` that works with UIKit and `@Observable` models, and can be integrated -with custom persistence strategies. To learn more, check out -[the documentation](https://swiftpackageindex.com/pointfreeco/swift-sharing/~/documentation/sharing/). - ## What is StructuredQueries? [StructuredQueries](https://github.com/pointfreeco/swift-structured-queries) is a library for @@ -267,8 +250,8 @@ databases (MySQL, Postgres, _etc._) and database libraries. [GRDB](https://github.com/groue/GRDB.swift) is a popular Swift interface to SQLite with a rich feature set and -[extensive documentation](https://swiftpackageindex.com/groue/GRDB.swift/documentation/grdb). -This library leverages GRDB's' observation APIs to keep the `@FetchAll`, `@FetchOne`, and `@Fetch` +[extensive documentation](https://swiftpackageindex.com/groue/GRDB.swift/documentation/grdb). This +library leverages GRDB's observation APIs to keep the `@FetchAll`, `@FetchOne`, and `@Fetch` property wrappers in sync with the database and update SwiftUI views. If you're already familiar with SQLite, GRDB provides thin APIs that can be leveraged with raw SQL @@ -293,12 +276,24 @@ with SQLite to take full advantage of GRDB and SQLiteData. - ``Dependencies/DependencyValues/defaultDatabase`` -### Fetch and observing queries +### Fetching and observing queries - ``FetchAll`` - ``FetchOne`` - ``Fetch`` +### Executing statements + +- ``StructuredQueriesCore/Statement/execute(_:)`` +- ``StructuredQueriesCore/Statement/fetchAll(_:)`` +- ``StructuredQueriesCore/Statement/fetchOne(_:)`` +- ``StructuredQueriesCore/Statement/fetchCursor(_:)`` +- ``StructuredQueriesCore/SelectStatement/fetchCount(_:)`` + +### Seeding data + +- ``GRDB/Database/seed(_:)`` + ### Deprecated interfaces - diff --git a/Sources/SQLiteData/Documentation.docc/SharingGRDB.md b/Sources/SQLiteData/Documentation.docc/SharingGRDB.md deleted file mode 100644 index e474fe90..00000000 --- a/Sources/SQLiteData/Documentation.docc/SharingGRDB.md +++ /dev/null @@ -1,44 +0,0 @@ -# ``SQLiteData`` - -A fast, lightweight replacement for SwiftData, powered by SQL and supporting CloudKit -synchronization. - -## Overview - -The core functionality of this library is defined in -[`SQLiteDataCore`](sqlitedatacore) and [`StructuredQueriesGRDBCore`](structuredquereisgrdbcore), -which this module automatically exports. - -> Note: This module also exports `StructuredQueries`, which provides the `@Table` macro for building -> and decoding queries. If you are using [GRDB][]'s built-in tools instead of -> [StructuredQueries][], consider depending on `SQLiteDataCore`, instead. - -See [`SQLiteDataCore`](sqlitedatacore) for documentation on the integration with the -`@FetchAll` property wrapper, which is equivalent to SwiftData's `@Query`. - -See [`StructuredQueriesGRDBCore`](sqlitedatacore) for documentation on the integration between -[StructuredQueries][] and [GRDB][]. - -> Tip: SQLiteData's primary product is the `SQLiteData` module, which includes all of the -> library's functionality, including the `@Fetch` family of property wrappers, the `@Table` macro, -> and tools for driving StructuredQueries using GRDB. This is the module that most library users -> should depend on. -> -> If you are a library author that wishes to extend SQLiteData with additional functionality, you -> may want to depend on a different module: -> -> * [`SQLiteDataCore`](sqlitedatacore): This product includes everything in `SQLiteData` -> _except_ the macros (`@Table`, `#sql`, _etc._). This module can be imported to extend -> SQLiteData with additional functionality without forcing the heavyweight dependency of -> SwiftSyntax on your users. -> * `StructuredQueriesGRDB`: This product includes everything in `SQLiteData` _except_ the -> `@Fetch` family of property wrappers. It can be imported if you want to extend -> StructuredQueries' GRDB driver but do not need access to observation tools provided by -> Sharing. -> * [`StructuredQueriesGRDBCore`](sqlitedatacore): This product includes everything in -> `StructuredQueriesGRDB` _except_ the macros. This module can be imported to extend -> StructuredQueries' GRDB driver with additional functionality without forcing the heavyweight -> dependency of SwiftSyntax on your users. - -[GRDB]: https://github.com/groue/GRDB.swift -[StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries diff --git a/Sources/SQLiteData/Exports.swift b/Sources/SQLiteData/Exports.swift index d8ac607a..af0d5981 100644 --- a/Sources/SQLiteData/Exports.swift +++ b/Sources/SQLiteData/Exports.swift @@ -1,2 +1,13 @@ -@_exported import SQLiteDataCore -@_exported import StructuredQueriesGRDB +@_exported import Dependencies +@_exported import Sharing +@_exported import StructuredQueriesSQLite + +@_exported import struct GRDB.Configuration +@_exported import class GRDB.Database +@_exported import struct GRDB.DatabaseError +@_exported import struct GRDB.DatabaseMigrator +@_exported import class GRDB.DatabasePool +@_exported import class GRDB.DatabaseQueue +@_exported import protocol GRDB.DatabaseReader +@_exported import protocol GRDB.DatabaseWriter +@_exported import protocol GRDB.ValueObservationScheduler diff --git a/Sources/SQLiteDataCore/Internal/DataManager.swift b/Sources/SQLiteData/Internal/DataManager.swift similarity index 100% rename from Sources/SQLiteDataCore/Internal/DataManager.swift rename to Sources/SQLiteData/Internal/DataManager.swift diff --git a/Sources/SQLiteDataCore/Internal/Deprecations.swift b/Sources/SQLiteData/Internal/Deprecations.swift similarity index 100% rename from Sources/SQLiteDataCore/Internal/Deprecations.swift rename to Sources/SQLiteData/Internal/Deprecations.swift diff --git a/Sources/StructuredQueriesGRDBCore/Internal/ISO8601.swift b/Sources/SQLiteData/Internal/ISO8601.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/Internal/ISO8601.swift rename to Sources/SQLiteData/Internal/ISO8601.swift diff --git a/Sources/SQLiteDataCore/Internal/StatementKey.swift b/Sources/SQLiteData/Internal/StatementKey.swift similarity index 100% rename from Sources/SQLiteDataCore/Internal/StatementKey.swift rename to Sources/SQLiteData/Internal/StatementKey.swift diff --git a/Sources/SQLiteDataCore/Internal/UserDatabase.swift b/Sources/SQLiteData/Internal/UserDatabase.swift similarity index 100% rename from Sources/SQLiteDataCore/Internal/UserDatabase.swift rename to Sources/SQLiteData/Internal/UserDatabase.swift diff --git a/Sources/SQLiteDataCore/Fetch.swift b/Sources/SQLiteData/SQLiteDataCore/Fetch.swift similarity index 100% rename from Sources/SQLiteDataCore/Fetch.swift rename to Sources/SQLiteData/SQLiteDataCore/Fetch.swift diff --git a/Sources/SQLiteDataCore/FetchAll.swift b/Sources/SQLiteData/SQLiteDataCore/FetchAll.swift similarity index 100% rename from Sources/SQLiteDataCore/FetchAll.swift rename to Sources/SQLiteData/SQLiteDataCore/FetchAll.swift diff --git a/Sources/SQLiteDataCore/FetchKey+SwiftUI.swift b/Sources/SQLiteData/SQLiteDataCore/FetchKey+SwiftUI.swift similarity index 100% rename from Sources/SQLiteDataCore/FetchKey+SwiftUI.swift rename to Sources/SQLiteData/SQLiteDataCore/FetchKey+SwiftUI.swift diff --git a/Sources/SQLiteDataCore/FetchKey.swift b/Sources/SQLiteData/SQLiteDataCore/FetchKey.swift similarity index 99% rename from Sources/SQLiteDataCore/FetchKey.swift rename to Sources/SQLiteData/SQLiteDataCore/FetchKey.swift index 834dad73..96cef7d4 100644 --- a/Sources/SQLiteDataCore/FetchKey.swift +++ b/Sources/SQLiteData/SQLiteDataCore/FetchKey.swift @@ -3,7 +3,6 @@ import Dispatch import Foundation import GRDB import Sharing -import StructuredQueriesGRDBCore #if canImport(Combine) @preconcurrency import Combine diff --git a/Sources/SQLiteDataCore/FetchKeyRequest.swift b/Sources/SQLiteData/SQLiteDataCore/FetchKeyRequest.swift similarity index 100% rename from Sources/SQLiteDataCore/FetchKeyRequest.swift rename to Sources/SQLiteData/SQLiteDataCore/FetchKeyRequest.swift diff --git a/Sources/SQLiteDataCore/FetchOne.swift b/Sources/SQLiteData/SQLiteDataCore/FetchOne.swift similarity index 100% rename from Sources/SQLiteDataCore/FetchOne.swift rename to Sources/SQLiteData/SQLiteDataCore/FetchOne.swift diff --git a/Sources/StructuredQueriesGRDBCore/CustomFunctions.swift b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/CustomFunctions.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift diff --git a/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift diff --git a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/QueryCursor.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift diff --git a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift diff --git a/Sources/StructuredQueriesGRDBCore/Seed.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Seed.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/Seed.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/Seed.swift diff --git a/Sources/StructuredQueriesGRDBCore/Statement+GRDB.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Statement+GRDB.swift similarity index 100% rename from Sources/StructuredQueriesGRDBCore/Statement+GRDB.swift rename to Sources/SQLiteData/StructuredQueries+GRDB/Statement+GRDB.swift diff --git a/Sources/SQLiteDataCore/Traits/Tagged.swift b/Sources/SQLiteData/Traits/Tagged.swift similarity index 100% rename from Sources/SQLiteDataCore/Traits/Tagged.swift rename to Sources/SQLiteData/Traits/Tagged.swift diff --git a/Sources/SQLiteDataCore/Internal/Exports.swift b/Sources/SQLiteDataCore/Internal/Exports.swift deleted file mode 100644 index df2a2de6..00000000 --- a/Sources/SQLiteDataCore/Internal/Exports.swift +++ /dev/null @@ -1,13 +0,0 @@ -@_exported import Dependencies -@_exported import Sharing -@_exported import StructuredQueriesGRDBCore - -@_exported import struct GRDB.Configuration -@_exported import class GRDB.Database -@_exported import struct GRDB.DatabaseError -@_exported import struct GRDB.DatabaseMigrator -@_exported import class GRDB.DatabasePool -@_exported import class GRDB.DatabaseQueue -@_exported import protocol GRDB.DatabaseReader -@_exported import protocol GRDB.DatabaseWriter -@_exported import protocol GRDB.ValueObservationScheduler diff --git a/Sources/SQLiteDataTestSupport/AssertQuery.swift b/Sources/SQLiteDataTestSupport/AssertQuery.swift index 86ca41f7..d381e981 100644 --- a/Sources/SQLiteDataTestSupport/AssertQuery.swift +++ b/Sources/SQLiteDataTestSupport/AssertQuery.swift @@ -3,8 +3,8 @@ import Dependencies import Foundation import GRDB import InlineSnapshotTesting +import SQLiteData import StructuredQueriesCore -import StructuredQueriesGRDBCore import StructuredQueriesTestSupport /// An end-to-end snapshot testing helper for database content. diff --git a/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md b/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md deleted file mode 100644 index d7958ace..00000000 --- a/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md +++ /dev/null @@ -1,10 +0,0 @@ -# ``StructuredQueriesGRDB`` - -A library interfacing StructuredQueries with GRDB. This module is automatically imported when you -`import SQLiteData`. - -## Overview - -The core functionality of this module is defined in -[`StructuredQueriesGRDBCore`](structuredqueriesgrdbcore) and then re-exported alongside -`StructuredQueries` and its macros. diff --git a/Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift b/Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift deleted file mode 100644 index 047e9bfe..00000000 --- a/Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift +++ /dev/null @@ -1,3 +0,0 @@ -@_exported import StructuredQueries -@_exported import StructuredQueriesSQLite -@_exported import StructuredQueriesGRDBCore diff --git a/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md b/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md deleted file mode 100644 index 0634ec99..00000000 --- a/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md +++ /dev/null @@ -1,70 +0,0 @@ -# ``StructuredQueriesGRDBCore`` - -The core functionality of interfacing StructuredQueries with GRDB. This module is automatically -imported when you `import SQLiteData` or `StructuredQueriesGRDB`. - -## Overview - -This library can be used to directly execute queries built using the [StructuredQueries][sq-gh] -library and a [GRDB][grdb-gh] database. - -While the `SQLiteData` module provides tools to observe queries using the `@FetchAll`, `@FetchOne`, -and `@Fetch` property wrappers, you will also want to execute one-off queries directly, especially -when it comes to `INSERT`, `UPDATE`, and `DELETE` statements. This module extends -StructuredQueries' `Statement` type with `execute`, `fetchAll`, `fetchOne`, and `fetchCount` methods -that execute the query on a given GRDB database. - -```swift -@Table -struct Player { - let id: Int - var name = "" - var score = 0 -} - -try #sql( - """ - CREATE TABLE players ( - id INTEGER PRIMARY KEY, - name TEXT, - score INTEGER - ) - """ -) -.execute(db) - -let players = Player - .where { $0.score > 10 } - .fetchAll(db) -// SELECT … FROM "players" -// WHERE "players"."score" > 10 - -let averageScore = try Player - .select { $0.score.avg() } - .fetchOne(db) -// SELECT avg("players"."score") FROM "players" -``` - -For more information on how to build queries, see the [StructuredQueries documentation][sq-spi]. - -[sq-gh]: https://github.com/pointfreeco/swift-structured-queries -[sq-spi]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore -[grdb-gh]: https://github.com/groue/GRDB.swift - -## Topics - -### Executing statements - -- ``StructuredQueriesCore/Statement/execute(_:)`` -- ``StructuredQueriesCore/Statement/fetchAll(_:)`` -- ``StructuredQueriesCore/Statement/fetchOne(_:)`` -- ``StructuredQueriesCore/Statement/fetchCursor(_:)`` -- ``StructuredQueriesCore/SelectStatement/fetchCount(_:)`` - -### Iterating over rows - -- ``QueryCursor`` - -### Seeding data - -- ``GRDB/Database/seed(_:)`` diff --git a/Sources/StructuredQueriesGRDBCore/Internal/Exports.swift b/Sources/StructuredQueriesGRDBCore/Internal/Exports.swift deleted file mode 100644 index e29fce7e..00000000 --- a/Sources/StructuredQueriesGRDBCore/Internal/Exports.swift +++ /dev/null @@ -1,2 +0,0 @@ -@_exported import StructuredQueriesCore -@_exported import StructuredQueriesSQLiteCore diff --git a/Tests/StructuredQueriesGRDBTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift similarity index 96% rename from Tests/StructuredQueriesGRDBTests/CustomFunctionTests.swift rename to Tests/SQLiteDataTests/CustomFunctionTests.swift index 003e3370..1442d976 100644 --- a/Tests/StructuredQueriesGRDBTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -1,6 +1,6 @@ import Foundation import GRDB -import StructuredQueriesGRDB +import SQLiteData import Testing @Suite struct CustomFunctionsTests { diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 92b51982..1695df40 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -2,7 +2,7 @@ import CloudKit import ConcurrencyExtras import CustomDump import OrderedCollections -import SQLiteDataCore +import SQLiteData import Testing @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift b/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift index cd48bfae..74c93a9d 100644 --- a/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift @@ -1,5 +1,5 @@ import GRDB -import SQLiteDataCore +import SQLiteData extension UserDatabase { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/StructuredQueriesGRDBTests/MigrationTests.swift b/Tests/SQLiteDataTests/MigrationTests.swift similarity index 97% rename from Tests/StructuredQueriesGRDBTests/MigrationTests.swift rename to Tests/SQLiteDataTests/MigrationTests.swift index 51f1b116..0bb17714 100644 --- a/Tests/StructuredQueriesGRDBTests/MigrationTests.swift +++ b/Tests/SQLiteDataTests/MigrationTests.swift @@ -1,6 +1,6 @@ import Foundation import GRDB -import StructuredQueriesGRDB +import SQLiteData import Testing @Suite struct MigrationTests { diff --git a/Tests/StructuredQueriesGRDBTests/QueryCursorTests.swift b/Tests/SQLiteDataTests/QueryCursorTests.swift similarity index 96% rename from Tests/StructuredQueriesGRDBTests/QueryCursorTests.swift rename to Tests/SQLiteDataTests/QueryCursorTests.swift index 9e07b462..4b018cf2 100644 --- a/Tests/StructuredQueriesGRDBTests/QueryCursorTests.swift +++ b/Tests/SQLiteDataTests/QueryCursorTests.swift @@ -1,5 +1,5 @@ import GRDB -import StructuredQueriesGRDB +import SQLiteData import Testing @Suite struct QueryCursorTests { diff --git a/Tests/SharingGRDB.xctestplan b/Tests/SharingGRDB.xctestplan deleted file mode 100644 index 898099c8..00000000 --- a/Tests/SharingGRDB.xctestplan +++ /dev/null @@ -1,31 +0,0 @@ -{ - "configurations" : [ - { - "id" : "B0A22F73-0252-40F2-892F-A609B4DFBBCA", - "name" : "Test Scheme Action", - "options" : { - - } - } - ], - "defaultOptions" : { - "codeCoverage" : false - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:", - "identifier" : "StructuredQueriesGRDBTests", - "name" : "StructuredQueriesGRDBTests" - } - }, - { - "target" : { - "containerPath" : "container:", - "identifier" : "SQLiteDataTests", - "name" : "SQLiteDataTests" - } - } - ], - "version" : 1 -} From 396ad9fabafa27ae58220fdde06d9c90b0ce0543 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 15:55:29 -0700 Subject: [PATCH 514/581] wip --- .spi.yml | 6 +- README.md | 67 +++------ .../Documentation.docc/Articles/CloudKit.md | 2 +- .../Articles/CloudKitSharing.md | 140 +++++++++--------- .../Documentation.docc/Extensions/Fetch.md | 4 +- .../Documentation.docc/Extensions/FetchAll.md | 2 +- .../Documentation.docc/Extensions/FetchKey.md | 2 +- .../Extensions/FetchKeyRequest.md | 2 +- .../Documentation.docc/Extensions/FetchOne.md | 3 +- .../SQLiteData/SQLiteDataCore/FetchAll.swift | 16 ++ 10 files changed, 112 insertions(+), 132 deletions(-) diff --git a/.spi.yml b/.spi.yml index 7bfee293..1b1003ec 100644 --- a/.spi.yml +++ b/.spi.yml @@ -2,8 +2,6 @@ version: 1 builder: configs: - documentation_targets: - - SharingGRDBCore - - SharingGRDB - - StructuredQueriesGRDB - - StructuredQueriesGRDBCore + - SQLiteData custom_documentation_parameters: [--enable-experimental-overloaded-symbol-presentation] + platform: ios diff --git a/README.md b/README.md index 452e9f5a..c0bf6f10 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,6 @@ -> [!IMPORTANT] -> We are currently running a [public beta] to preview our upcoming CloudKit synchronization tools. Get all the details [here](https://www.pointfree.co/blog/posts/181-a-swiftdata-alternative-with-sqlite-cloudkit-public-beta) and let us know if you have any feedback! - -[public beta]: https://github.com/pointfreeco/sqlite-data/pull/112 - # SQLiteData -A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL and supporting -CloudKit synchronization. +A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL. [![CI](https://github.com/pointfreeco/sqlite-data/actions/workflows/ci.yml/badge.svg)](https://github.com/pointfreeco/sqlite-data/actions/workflows/ci.yml) [![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.pointfree.co/slack-invite) @@ -39,9 +33,10 @@ library, [subscribe today](https://www.pointfree.co/pricing). ## Overview -SQLiteData is a [fast](#performance), lightweight replacement for SwiftData that deploys all the -way back to the iOS 13 generation of targets. To populate data from the database you can use -the `@FetchAll` property wrapper, which is similar to SwiftData's `@Query` macro: +SQLiteData is a [fast](#performance), lightweight replacement for SwiftData, including CloudKit +synchronization (and even CloudKit sharing) that deploys all the way back to the iOS 13 generation +of targets. To populate data from the database you can use `@Table` and @FetchAll`, which are +similar to SwiftData's `@Model` and `@Query`: @@ -94,8 +89,8 @@ class Item { Both of the above examples fetch items from an external data store using Swift data types, and both are automatically observed by SwiftUI so that views are recomputed when the external data changes, -but SQLiteData is powered directly by SQLite using [Sharing][], [StructuredQueries][], and -[GRDB][], and is usable from UIKit, `@Observable` models, and more. +but SQLiteData is powered directly by SQLite and is usable from UIKit, `@Observable` models, and +more. For more information on SQLiteData's querying capabilities, see [Fetching model data][fetching-article]. @@ -288,15 +283,15 @@ the [articles][articles] below to learn how to best utilize this library: * [CloudKit Synchronization] * [Comparison with SwiftData][comparison-swiftdata-article] -[observing-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/observing -[dynamic-queries-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/dynamicqueries -[articles]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore#Essentials -[comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/comparisonwithswiftdata -[fetching-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/fetching -[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/preparingdatabase -[CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/cloudkit -[fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/fetchall -[fetchone-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/fetchone +[observing-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/observing +[dynamic-queries-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/dynamicqueries +[articles]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata#Essentials +[comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/comparisonwithswiftdata +[fetching-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/fetching +[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/preparingdatabase +[CloudKit Synchronization]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/cloudkit +[fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/fetchall +[fetchone-docs]: https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/fetchone ## Performance @@ -332,7 +327,6 @@ for data and keep your views up-to-date when data in the database changes, and y [StructuredQueries][] to build queries, either using its type-safe, discoverable [query building APIs][], or using its `#sql` macro for writing [safe SQL strings][]. -[Sharing]: https://github.com/pointfreeco/swift-sharing [StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries [GRDB]: https://github.com/groue/GRDB.swift [query building APIs]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore @@ -341,7 +335,7 @@ for data and keep your views up-to-date when data in the database changes, and y ## Demos This repo comes with _lots_ of examples to demonstrate how to solve common and complex problems with -Sharing. Check out [this](./Examples) directory to see them all, including: +SQLiteData. Check out [this](./Examples) directory to see them all, including: * [Case Studies](./Examples/CaseStudies): A number of case studies demonstrating the built-in features of the library. @@ -361,8 +355,8 @@ Sharing. Check out [this](./Examples) directory to see them all, including: The documentation for releases and `main` are available here: - * [`main`](https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedatabcore/) - * [0.x.x](https://swiftpackageindex.com/pointfreeco/sqlite-data/~/documentation/sqlitedatabcore/) + * [`main`](https://swiftpackageindex.com/pointfreeco/sqlite-data/main/documentation/sqlitedata/) + * [0.x.x](https://swiftpackageindex.com/pointfreeco/sqlite-data/~/documentation/sqlitedata/) ## Installation @@ -372,33 +366,12 @@ You can add SQLiteData to an Xcode project by adding it to your project as a pac …and adding the `SQLiteData` product to your target. -> [!TIP] -> SQLiteData's primary product is the `SQLiteData` module, which includes all of the library's -> functionality, including the `@Fetch` family of property wrappers, the `@Table` macro, and tools -> for driving StructuredQueries using GRDB. This is the module that most library users should depend -> on. -> -> If you are a library author that wishes to extend SQLiteData with additional functionality, you -> may want to depend on a different module: -> -> * `SQLiteDataCore`: This product includes everything in `SQLiteData` _except_ the macros -> (`@Table`, `#sql`, _etc._). This module can be imported to extend SQLiteData with additional -> functionality without forcing the heavyweight dependency of SwiftSyntax on your users. -> * `StructuredQueriesGRDB`: This product includes everything in `SQLiteData` _except_ the -> `@Fetch` family of property wrappers. It can be imported if you want to extend -> StructuredQueries' GRDB driver but do not need access to observation tools provided by -> Sharing. -> * `StructuredQueriesGRDBCore`: This product includes everything in `StructuredQueriesGRDB` -> _except_ the macros. This module can be imported to extend StructuredQueries' GRDB driver with -> additional functionality without forcing the heavyweight dependency of SwiftSyntax on your -> users. - If you want to use SQLiteData in a [SwiftPM](https://swift.org/package-manager/) project, it's as simple as adding it to your `Package.swift`: ``` swift dependencies: [ - .package(url: "https://github.com/pointfreeco/sqlite-data", from: "0.6.0") + .package(url: "https://github.com/pointfreeco/sqlite-data", from: "1.0.0") ] ``` diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 83e3f70b..7d5b9331 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -92,7 +92,7 @@ struct MyApp: App { ``` The `SyncEngine` -[initializer]() +[initializer]() has more options you may be interested in configuring. > Important: You must explicitly provide all tables that you want to synchronize. We do this so that diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md index 307eea8d..5fd490bd 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md @@ -4,13 +4,13 @@ Learn how to allow your users to share certain records with other iCloud users f ## Overview -SQLiteData provides the tools necessary to share a record with another iCloud user so that -multiple users can collaborate on a single record. Sharing a record with another user brings -extra complications to an app that go beyond the existing complications of sharing a schema -across many devices. Please read the documentation carefully and thoroughly to understand -how to best design your schema for sharing that does not cause problems down the road. +SQLiteData provides the tools necessary to share a record with another iCloud user so that multiple +users can collaborate on a single record. Sharing a record with another user brings extra +complications to an app that go beyond the existing complications of sharing a schema across many +devices. Please read the documentation carefully and thoroughly to understand how to best design +your schema for sharing that does not cause problems down the road. -> Important: To enable sharing of records be sure to add a `CKSharingSupported` key to your +> Important: To enable sharing of records be sure to add a `CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented in [Apple's documentation for sharing]. [Apple's documentation for sharing]: https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users#Create-and-Share-a-Topic @@ -29,14 +29,14 @@ Info.plist with a value of `true`. This is subtly documented in [Apple's documen ## Creating CKShare records -To share a record with another user one must first create a `CKShare`. SQLiteData provides -the method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` -for a record. Further, the value returned from this method can be stored in a view and be used -to drive a sheet to display a ``CloudSharingView``, which is a wrapper around UIKit's +To share a record with another user one must first create a `CKShare`. SQLiteData provides the +method ``SyncEngine/share(record:configure:)`` on ``SyncEngine`` for generating a `CKShare` for a +record. Further, the value returned from this method can be stored in a view and be used to drive a +sheet to display a ``CloudSharingView``, which is a wrapper around UIKit's `UICloudSharingController`. -As an example, a reminders app that wants to allow sharing a reminders list with another user -can do so like this: +As an example, a reminders app that wants to allow sharing a reminders list with another user can do +so like this: ```swift struct RemindersListView: View { @@ -65,17 +65,17 @@ struct RemindersListView: View { } ``` -When the "Share" button is tapped, a ``SharedRecord`` will be generated and stored as local state -in the view. That will cause a ``CloudSharingView`` sheet to be presented where the user can -configure how they want to share the record. A record can be _unshared_ by presenting the same +When the "Share" button is tapped, a ``SharedRecord`` will be generated and stored as local state in +the view. That will cause a ``CloudSharingView`` sheet to be presented where the user can configure +how they want to share the record. A record can be _unshared_ by presenting the same ``CloudSharingView`` to the user so that they can tap the "Stop sharing" button in the UI. ## Accepting shared records -Extra steps must be taken to allow a user to _accept_ a shared record. Once the user taps on the -share link sent to them (whether that is by text, email, etc.), the app will be launched with +Extra steps must be taken to allow a user to _accept_ a shared record. Once the user taps on the +share link sent to them (whether that is by text, email, etc.), the app will be launched with special options provided or a special delegate method will be invoked in the app's scene delegate. -You must implement these delegate methods and invoke the ``SyncEngine/acceptShare(metadata:)`` +You must implement these delegate methods and invoke the ``SyncEngine/acceptShare(metadata:)`` method. As a simplified example, a `UIWindowSceneDelegate` subclass can implement the delegate method like @@ -116,7 +116,7 @@ and the `acceptShare` method is async. ## Diving deeper into sharing -The above gives a broad overview of how one shares a record with a user, and how a user accepts a +The above gives a broad overview of how one shares a record with a user, and how a user accepts a shared record. There is, however, a lot more to know about sharing. There are important restrictions placed on what kind of records you are allowed to share, and what associations of those records are shared. @@ -157,8 +157,8 @@ struct Reminder: Identifiable { } ``` -Such records cannot be shared because it is not appropriate to also share the parent record -(_i.e._ the reminders list). +Such records cannot be shared because it is not appropriate to also share the parent record (_i.e._ +the reminders list). For example, suppose you have a list named "Personal" with a reminder "Get milk". If you share this reminder with someone, then it becomes difficult to figure out what to do when they make certain @@ -166,13 +166,12 @@ changes to the reminder: * If they decide to reassign the reminder to their personal "Life" list, what should happen? Should their "Life" list suddenly be synchronized to your device? - * Or what if they delete the list? Would you want that to delete your list and all of the reminders - in the list? + * Or what if they delete the list? Would you want that to delete your list and all of the + reminders in the list? For these reasons, and more, it is not possible to share non-root records, like reminders. Instead, you can share root records, like reminders lists. If you do invoke -``SyncEngine/share(record:configure:)`` with a non-root record, a -``SyncEngine/CantShareRecordWithParent`` error will be thrown. +``SyncEngine/share(record:configure:)`` with a non-root record, an error will be thrown. > Note: A reminder can still be shared as an association to a shared reminders list, as discussed > [in the next section](). However, a single @@ -186,19 +185,19 @@ For a more complex example, consider the following diagrammatic schema for a rem In this schema, a `RemindersList` can have many `Reminder`s, can have a `CoverImage`, and a `Reminder` can have multiple `Tag`s, and vice-versa. The only table in this diagram that constitutes -a "root" is `RemindersList`. It is the only one with no foreign key relationships. -None of `Reminder`, `CoverImage`, `Tag` or `ReminderTag` can be directly shared on their own -because they are not root tables. +a "root" is `RemindersList`. It is the only one with no foreign key relationships. None of +`Reminder`, `CoverImage`, `Tag` or `ReminderTag` can be directly shared on their own because they +are not root tables. #### Sharing foreign key relationships > Important: Foreign key relationships are automatically synchronized, but only if the related > record has a single foreign key. Records with multiple foreign keys cannot be synchronized. -Relationships between models will automatically be shared when sharing a root record, but with -some limitations. An associated record of a shared record will only be shared if it has exactly -one foreign key pointing to the root shared record, whether directly or indirectly -through other records satisfying this property. +Relationships between models will automatically be shared when sharing a root record, but with some +limitations. An associated record of a shared record will only be shared if it has exactly one +foreign key pointing to the root shared record, whether directly or indirectly through other records +satisfying this property. Below we describe some of the most common types of relationships in SQL databases, as well as which are possible to synchronize, which cannot be synchronized, and which can be adapted to @@ -206,8 +205,8 @@ play nicely with synchronization. ##### One-to-many relationships -One-to-many relationships are the simplest to share with other users. As an example, -consider a `RemindersList` table that can have many `Reminder`s associated with it: +One-to-many relationships are the simplest to share with other users. As an example, consider a +`RemindersList` table that can have many `Reminder`s associated with it: ```swift @Table @@ -228,7 +227,7 @@ struct Reminder: Identifiable { Since `RemindersList` is a [root record](#Sharing-root-records) it can be shared, and since `Reminder` has only one foreign key pointing to `RemindersList`, it too will be shared. -Further, suppose there was a `ChildReminder` table that had a single foreign key pointing to a +Further, suppose there was a `ChildReminder` table that had a single foreign key pointing to a `Reminder`: ```swift @@ -241,8 +240,8 @@ struct ChildReminder: Identifiable { } ``` -This too will be shared because it has one single foreign key pointing to a table that also has -one single foreign key pointing to the root record being shared. +This too will be shared because it has one single foreign key pointing to a table that also has one +single foreign key pointing to the root record being shared. As a more complex example, consider the following diagrammatic schema: @@ -250,21 +249,19 @@ As a more complex example, consider the following diagrammatic schema: The green node is a shareable root record, and all blue records are relationships that will also be shared when the root is shared. } -![Synchronizing one-to-many relationships]() -In this schema, a `RemindersList` can have many `Reminder`s and a `CoverImage`, and a `Reminder` -can have many `ChildReminder`s. Sharing a `RemindersList` will share all associated reminders, -cover image, and even child reminders. The child reminders are synchronized because it has a -single foreign key pointing to a table that also has a single foreign key pointing to the root -record. +In this schema, a `RemindersList` can have many `Reminder`s and a `CoverImage`, and a `Reminder` can +have many `ChildReminder`s. Sharing a `RemindersList` will share all associated reminders, cover +image, and even child reminders. The child reminders are synchronized because it has a single +foreign key pointing to a table that also has a single foreign key pointing to the root record. ##### Many-to-many relationships -Many-to-many relationships pose a significant problem to sharing and cannot be supported. If a -table has multiple foreign keys, then it will not be shared even if one of those -foreign keys points to the shared record. +Many-to-many relationships pose a significant problem to sharing and cannot be supported. If a table +has multiple foreign keys, then it will not be shared even if one of those foreign keys points to +the shared record. -As an example, suppose we had a many-to-many association of a `Tag` table to `Reminder` via a +As an example, suppose we had a many-to-many association of a `Tag` table to `Reminder` via a `ReminderTag` join table: ```swift @@ -288,19 +285,18 @@ In diagrammatic form, this schema looks like the following: and the light purple records cannot be shared. } -The `ReminderTag` records will _not_ be shared because it has two foreign key -relationships, represented by the two arrows leaving the `ReminderTag` node. As a consequence, -the `Tag` records will also not be shared. Sharing these records cannot be done in a consistent and -logical manner. +The `ReminderTag` records will _not_ be shared because it has two foreign key relationships, +represented by the two arrows leaving the `ReminderTag` node. As a consequence, the `Tag` records +will also not be shared. Sharing these records cannot be done in a consistent and logical manner. -> Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing -> many-to-many relationships. This is also how the Reminders app works on Apple's platforms. -> Sharing a list of reminders with another use does not share its tags with that user. +> Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing +> many-to-many relationships. This is also how the Reminders app works on Apple's platforms. Sharing +> a list of reminders with another use does not share its tags with that user. -To see why this is an acceptable limitation, suppose you share a "Personal" list with someone, which -holds a "Get milk" reminder, and that reminder has a "weekend" tag associated with it. If the tag -were shared with your friend, then what happens when they delete the tag? Would it be appropriate to -delete that tag from all of your reminders, even the ones that were not shared? For this reason, +To see why this is an acceptable limitation, suppose you share a "Personal" list with someone, which +holds a "Get milk" reminder, and that reminder has a "weekend" tag associated with it. If the tag +were shared with your friend, then what happens when they delete the tag? Would it be appropriate to +delete that tag from all of your reminders, even the ones that were not shared? For this reason, and more, records with multiple foreign keys cannot be shared with a record. If you want to support many tags associated with a single reminder, you will have no choice @@ -318,8 +314,8 @@ struct Tag: Identifiable { In diagrammatic form this schema now looks like the following: @Image(source: sync-diagram-many-to-many-refactor.png) { - The green record is a shareable root record, and the blue records will be shared when the root - is shared. + The green record is a shareable root record, and the blue records will be shared when the root is + shared. } This kind of relationship will now be synchronized automatically. Sharing a `RemindersList` will @@ -334,9 +330,9 @@ excels at. ##### One-to-"at most one" relationships One-to-"at most one" relationships in SQLite allow you to associate zero or one records with -another record. For an example of this, suppose we wanted to hold onto a cover image for reminders -lists (see for more information on synchronizing assets such as images). It -is perfectly fine to hold onto large binary data in SQLite, such as image data, but typically one +another record. For an example of this, suppose we wanted to hold onto a cover image for reminders +lists (see for more information on synchronizing assets such as images). It +is perfectly fine to hold onto large binary data in SQLite, such as image data, but typically one should put this data in a separate table. The way to model this kind of relationship in SQLite is by making a foreign key point from the image @@ -368,7 +364,7 @@ data you share with other users. These permissions are automatically observed by enforced when writing to your database. If your application tries to write to a record that it does not have permission for, a `DatabaseError` will be emitted. -To check for this error you can catch `DatabaseError` and compare its message to +To check for this error you can catch `DatabaseError` and compare its message to ``SyncEngine/writePermissionError``: ```swift @@ -384,11 +380,11 @@ do { ``` See for more information on accessing the metadata -associationed with your user's data. +associated with your user's data. Ideally your app would not allow the user to write to records that they do not have permissions for. -To check their permissions for a record, you can join the root record table to -``SyncMetadata`` and select the ``SyncMetadata/share`` value: +To check their permissions for a record, you can join the root record table to ``SyncMetadata`` and +select the ``SyncMetadata/share`` value: ```swift let share = try await database.read { db in @@ -428,9 +424,9 @@ Sharing this record will mean also sharing the position of the list. That means reorders their local lists, even ones that are private to them, it will reorder the lists for everyone shared. This is probably not what you want. -So, private and non-shareable information about this record can be stored in a separate table, -and we can use the trick mentioned in -by making the foreign key of the table also be the table's primary key: +So, private and non-shareable information about this record can be stored in a separate table, and +we can use the trick mentioned in by making +the foreign key of the table also be the table's primary key: ```swift @Table @@ -446,8 +442,8 @@ struct RemindersListPrivate: Identifiable { } ``` -And then when creating the ``SyncEngine`` we can specifically ask it to not share this record -when the reminders list is shared by specifying the `privateTables` argument: +And then when creating the ``SyncEngine`` we can specifically ask it to not share this record when +the reminders list is shared by specifying the `privateTables` argument: ```swift @main diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md b/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md index 77139bb2..28d13115 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md @@ -1,4 +1,4 @@ -# ``SQLiteDataCore/Fetch`` +# ``SQLiteData/Fetch`` ## Overview @@ -8,8 +8,6 @@ - ``FetchKeyRequest`` - ``init(wrappedValue:_:database:)`` -- ``init(_:database:)`` -- ``init(database:)`` - ``init(wrappedValue:)`` - ``load(_:database:)`` diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md index 865531db..14c32601 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md @@ -1,4 +1,4 @@ -# ``SQLiteDataCore/FetchAll`` +# ``SQLiteData/FetchAll`` ## Overview diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md index cd7e7803..567eba3a 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md @@ -1,4 +1,4 @@ -# ``SQLiteDataCore/FetchKey`` +# ``SQLiteData/FetchKey`` ## Topics diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md index 2819ccf0..d0c6868e 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md @@ -1,4 +1,4 @@ -# ``SQLiteDataCore/FetchKeyRequest`` +# ``SQLiteData/FetchKeyRequest`` ## Topics diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md index 943466f0..787d2914 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchOne.md @@ -1,4 +1,4 @@ -# ``SQLiteDataCore/FetchOne`` +# ``SQLiteData/FetchOne`` ## Overview @@ -7,7 +7,6 @@ ### Fetching data - ``init(wrappedValue:database:)`` -- ``init(database:)`` - ``init(wrappedValue:_:database:)`` - ``load(_:database:)`` diff --git a/Sources/SQLiteData/SQLiteDataCore/FetchAll.swift b/Sources/SQLiteData/SQLiteDataCore/FetchAll.swift index 4a1ba03b..6dff41f2 100644 --- a/Sources/SQLiteData/SQLiteDataCore/FetchAll.swift +++ b/Sources/SQLiteData/SQLiteDataCore/FetchAll.swift @@ -70,6 +70,11 @@ public struct FetchAll: Sendable { #endif /// Initializes this property with a query that fetches every row from a table. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil @@ -88,6 +93,7 @@ public struct FetchAll: Sendable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -109,6 +115,7 @@ public struct FetchAll: Sendable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -133,6 +140,7 @@ public struct FetchAll: Sendable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -201,6 +209,7 @@ extension FetchAll { /// Initializes this property with a query that fetches every row from a table. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed @@ -218,6 +227,7 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -242,6 +252,7 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -270,6 +281,7 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -366,6 +378,7 @@ extension FetchAll: Equatable where Element: Equatable { /// Initializes this property with a query that fetches every row from a table. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to @@ -383,6 +396,7 @@ extension FetchAll: Equatable where Element: Equatable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -412,6 +426,7 @@ extension FetchAll: Equatable where Element: Equatable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). @@ -439,6 +454,7 @@ extension FetchAll: Equatable where Element: Equatable { /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). From 7cf7777fe308c2925384a3382f137872d73200a1 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:04:01 -0700 Subject: [PATCH 515/581] wip --- .../Documentation.docc/Extensions/Fetch.md | 4 +- .../{SQLiteDataCore => }/Fetch.swift | 0 .../{SQLiteDataCore => }/FetchAll.swift | 0 .../FetchKeyRequest.swift | 2 - .../{SQLiteDataCore => }/FetchOne.swift | 0 .../SQLiteData/Internal/Deprecations.swift | 555 ------------------ .../SQLiteData/{ => Internal}/Exports.swift | 0 .../Internal/FetchKey+SwiftUI.swift | 77 +++ Sources/SQLiteData/Internal/FetchKey.swift | 256 ++++++++ .../SQLiteDataCore/FetchKey+SwiftUI.swift | 138 ----- .../SQLiteData/SQLiteDataCore/FetchKey.swift | 434 -------------- 11 files changed, 334 insertions(+), 1132 deletions(-) rename Sources/SQLiteData/{SQLiteDataCore => }/Fetch.swift (100%) rename Sources/SQLiteData/{SQLiteDataCore => }/FetchAll.swift (100%) rename Sources/SQLiteData/{SQLiteDataCore => }/FetchKeyRequest.swift (99%) rename Sources/SQLiteData/{SQLiteDataCore => }/FetchOne.swift (100%) delete mode 100644 Sources/SQLiteData/Internal/Deprecations.swift rename Sources/SQLiteData/{ => Internal}/Exports.swift (100%) create mode 100644 Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift create mode 100644 Sources/SQLiteData/Internal/FetchKey.swift delete mode 100644 Sources/SQLiteData/SQLiteDataCore/FetchKey+SwiftUI.swift delete mode 100644 Sources/SQLiteData/SQLiteDataCore/FetchKey.swift diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md b/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md index 28d13115..0657e8f9 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/Fetch.md @@ -32,9 +32,7 @@ - ``init(wrappedValue:_:database:scheduler:)`` - ``load(_:database:scheduler:)`` -### Sharing infrastructure +### Sharing integration - ``sharedReader`` - ``subscript(dynamicMember:)`` -- ``FetchKey`` -- ``FetchKeyID`` diff --git a/Sources/SQLiteData/SQLiteDataCore/Fetch.swift b/Sources/SQLiteData/Fetch.swift similarity index 100% rename from Sources/SQLiteData/SQLiteDataCore/Fetch.swift rename to Sources/SQLiteData/Fetch.swift diff --git a/Sources/SQLiteData/SQLiteDataCore/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift similarity index 100% rename from Sources/SQLiteData/SQLiteDataCore/FetchAll.swift rename to Sources/SQLiteData/FetchAll.swift diff --git a/Sources/SQLiteData/SQLiteDataCore/FetchKeyRequest.swift b/Sources/SQLiteData/FetchKeyRequest.swift similarity index 99% rename from Sources/SQLiteData/SQLiteDataCore/FetchKeyRequest.swift rename to Sources/SQLiteData/FetchKeyRequest.swift index cd89d074..17f713a9 100644 --- a/Sources/SQLiteData/SQLiteDataCore/FetchKeyRequest.swift +++ b/Sources/SQLiteData/FetchKeyRequest.swift @@ -1,5 +1,3 @@ -import GRDB - /// A type that can request a value from a database. /// /// This type can be used to describe a transaction to read data from SQLite: diff --git a/Sources/SQLiteData/SQLiteDataCore/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift similarity index 100% rename from Sources/SQLiteData/SQLiteDataCore/FetchOne.swift rename to Sources/SQLiteData/FetchOne.swift diff --git a/Sources/SQLiteData/Internal/Deprecations.swift b/Sources/SQLiteData/Internal/Deprecations.swift deleted file mode 100644 index 96df088e..00000000 --- a/Sources/SQLiteData/Internal/Deprecations.swift +++ /dev/null @@ -1,555 +0,0 @@ -#if canImport(Combine) - import Combine -#endif -#if canImport(SwiftUI) - import SwiftUI -#endif - -// NB: Deprecated after 0.2.2 - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -@available( - *, - deprecated, - message: "Use the '@Selection' macro to bundle multiple values into a value." -) -extension FetchAll { - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: S, - database: (any DatabaseReader)? = nil - ) - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch(FetchAllStatementPackRequest(statement: statement), database: database) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil - ) - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil - ) async throws - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil - ) async throws - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: S, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database - ) - ) - } - - #if canImport(SwiftUI) - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: S, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: [Element] = [], - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil, - animation: Animation - ) async throws - where - Element == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == (repeat each J), - repeat (each J).QueryOutput: Sendable - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - animation: Animation - ) async throws - where - Element == (V1.QueryOutput, repeat (each V2).QueryOutput), - V1.QueryOutput: Sendable, - repeat (each V2).QueryOutput: Sendable - { - try await sharedReader.load( - .fetch( - FetchAllStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - #endif -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private struct FetchAllStatementPackRequest: StatementKeyRequest { - let statement: SQLQueryExpression<(repeat each Value)> - init(statement: some StructuredQueriesCore.Statement<(repeat each Value)>) { - self.statement = SQLQueryExpression(statement) - } - func fetch(_ db: Database) throws -> [(repeat (each Value).QueryOutput)] { - try statement.fetchAll(db) - } -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -@available( - *, - deprecated, - message: "Use the '@Selection' macro to bundle multiple values into a value." -) -extension FetchOne { - @_disfavoredOverload - public init( - wrappedValue: (S.From.QueryOutput, repeat (each J).QueryOutput), - _ statement: S, - database: (any DatabaseReader)? = nil - ) - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: (V1.QueryOutput, repeat (each V2).QueryOutput), - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil - ) - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil - ) async throws - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database - ) - ) - } - - /// Replaces the wrapped value with data from the given query. - /// - /// - Parameters: - /// - statement: A query associated with the wrapped value. - /// - database: The database to read from. A value of `nil` will use the default database - /// (`@Dependency(\.defaultDatabase)`). - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil - ) async throws - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: (S.From.QueryOutput, repeat (each J).QueryOutput), - _ statement: S, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: (V1.QueryOutput, repeat (each V2).QueryOutput), - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - scheduler: scheduler - ) - ) - } - - #if canImport(SwiftUI) - @_disfavoredOverload - public init( - wrappedValue: (S.From.QueryOutput, repeat (each J).QueryOutput), - _ statement: S, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public init( - wrappedValue: (V1.QueryOutput, repeat (each V2).QueryOutput), - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - sharedReader = SharedReader( - wrappedValue: wrappedValue, - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: S, - database: (any DatabaseReader)? = nil, - animation: Animation - ) async throws - where - Value == (S.From.QueryOutput, repeat (each J).QueryOutput), - S.QueryValue == (), - S.Joins == (repeat each J) - { - let statement = statement.selectStar() - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - @_disfavoredOverload - public func load( - _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, - database: (any DatabaseReader)? = nil, - animation: Animation - ) async throws - where - Value == (V1.QueryOutput, repeat (each V2).QueryOutput) - { - try await sharedReader.load( - .fetch( - FetchOneStatementPackRequest(statement: statement), - database: database, - animation: animation - ) - ) - } - - #endif -} - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private struct FetchOneStatementPackRequest: StatementKeyRequest { - let statement: SQLQueryExpression<(repeat each Value)> - init(statement: some StructuredQueriesCore.Statement<(repeat each Value)>) { - self.statement = SQLQueryExpression(statement) - } - func fetch(_ db: Database) throws -> (repeat (each Value).QueryOutput) { - guard let result = try statement.fetchOne(db) - else { throw NotFound() } - return result - } -} diff --git a/Sources/SQLiteData/Exports.swift b/Sources/SQLiteData/Internal/Exports.swift similarity index 100% rename from Sources/SQLiteData/Exports.swift rename to Sources/SQLiteData/Internal/Exports.swift diff --git a/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift new file mode 100644 index 00000000..56562e39 --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift @@ -0,0 +1,77 @@ +#if canImport(SwiftUI) + import GRDB + import Sharing + import SwiftUI + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SharedReaderKey { + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + animation: Animation + ) -> Self + where Self == FetchKey { + .fetch(request, database: database, scheduler: .animation(animation)) + } + + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + animation: Animation + ) -> Self + where Self == FetchKey.Default { + .fetch(request, database: database, scheduler: .animation(animation)) + } + + static func fetchAll( + sql: String, + arguments: StatementArguments = StatementArguments(), + database: (any DatabaseReader)? = nil, + animation: Animation + ) -> Self + where Self == FetchKey<[Record]>.Default { + .fetchAll( + sql: sql, + arguments: arguments, + database: database, + scheduler: .animation(animation) + ) + } + + static func fetchOne( + sql: String, + arguments: StatementArguments = StatementArguments(), + database: (any DatabaseReader)? = nil, + animation: Animation + ) -> Self + where Self == FetchKey { + .fetchOne( + sql: sql, + arguments: arguments, + database: database, + scheduler: .animation(animation) + ) + } + } + + package struct AnimatedScheduler: ValueObservationScheduler, Equatable { + let animation: Animation + package func immediateInitialValue() -> Bool { true } + package func schedule(_ action: @escaping @Sendable () -> Void) { + DispatchQueue.main.async { + withAnimation(animation) { + action() + } + } + } + } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension AnimatedScheduler: Hashable {} + + extension ValueObservationScheduler where Self == AnimatedScheduler { + package static func animation(_ animation: Animation) -> Self { + AnimatedScheduler(animation: animation) + } + } +#endif diff --git a/Sources/SQLiteData/Internal/FetchKey.swift b/Sources/SQLiteData/Internal/FetchKey.swift new file mode 100644 index 00000000..3f7f263e --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchKey.swift @@ -0,0 +1,256 @@ +import Dependencies +import Dispatch +import Foundation +import GRDB +import Sharing + +#if canImport(Combine) + @preconcurrency import Combine +#endif + +extension SharedReaderKey { + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil + ) -> Self + where Self == FetchKey { + FetchKey(request: request, database: database, scheduler: nil) + } + + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil + ) -> Self + where Self == FetchKey.Default { + Self[.fetch(request, database: database), default: Value()] + } + + static func fetchAll( + sql: String, + arguments: StatementArguments = StatementArguments(), + database: (any DatabaseReader)? = nil + ) -> Self + where Self == FetchKey<[Record]>.Default { + Self[ + .fetch(FetchAllRequest(sql: sql, arguments: arguments), database: database), + default: [] + ] + } + + static func fetchOne( + sql: String, + arguments: StatementArguments = StatementArguments(), + database: (any DatabaseReader)? = nil + ) -> Self + where Self == FetchKey { + .fetch(FetchOneRequest(sql: sql, arguments: arguments), database: database) + } +} + +extension SharedReaderKey { + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) -> Self + where Self == FetchKey { + FetchKey(request: request, database: database, scheduler: scheduler) + } + + static func fetch( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) -> Self + where Self == FetchKey.Default { + Self[.fetch(request, database: database, scheduler: scheduler), default: Value()] + } + + static func fetchAll( + sql: String, + arguments: StatementArguments = StatementArguments(), + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) -> Self + where Self == FetchKey<[Record]>.Default { + Self[ + .fetch( + FetchAllRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler + ), + default: [] + ] + } + + static func fetchOne( + sql: String, + arguments: StatementArguments = StatementArguments(), + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) -> Self + where Self == FetchKey { + .fetch( + FetchOneRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler + ) + } +} + +struct FetchKey: SharedReaderKey { + let database: any DatabaseReader + let request: any FetchKeyRequest + let scheduler: (any ValueObservationScheduler & Hashable)? + #if DEBUG + let isDefaultDatabase: Bool + #endif + + public typealias ID = FetchKeyID + + public var id: ID { + ID(database: database, request: request, scheduler: scheduler) + } + + init( + request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + @Dependency(\.defaultDatabase) var defaultDatabase + self.scheduler = scheduler + self.database = database ?? defaultDatabase + self.request = request + #if DEBUG + self.isDefaultDatabase = self.database.configuration.label == .defaultDatabaseLabel + #endif + } + + public func load(context: LoadContext, continuation: LoadContinuation) { + #if DEBUG + guard !isDefaultDatabase else { + continuation.resumeReturningInitialValue() + return + } + #endif + guard case .userInitiated = context else { + continuation.resumeReturningInitialValue() + return + } + let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler() + database.asyncRead { dbResult in + let result = dbResult.flatMap { db in + Result { + try request.fetch(db) + } + } + scheduler.schedule { + switch result { + case let .success(value): + continuation.resume(returning: value) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } + + public func subscribe( + context: LoadContext, subscriber: SharedSubscriber + ) -> SharedSubscription { + #if DEBUG + guard !isDefaultDatabase else { + return SharedSubscription {} + } + #endif + let observation = ValueObservation.tracking { db in + Result { try request.fetch(db) } + } + + let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler() + #if canImport(Combine) + let dropFirst = + switch context { + case .initialValue: false + case .userInitiated: true + } + let cancellable = observation.publisher(in: database, scheduling: scheduler) + .dropFirst(dropFirst ? 1 : 0) + .sink { completion in + switch completion { + case let .failure(error): + subscriber.yield(throwing: error) + case .finished: + break + } + } receiveValue: { newValue in + switch newValue { + case let .success(value): + subscriber.yield(value) + case let .failure(error): + subscriber.yield(throwing: error) + } + } + return SharedSubscription { + cancellable.cancel() + } + #else + let cancellable = observation.start(in: database, scheduling: scheduler) { error in + subscriber.yield(throwing: error) + } onChange: { newValue in + switch newValue { + case let .success(value): + subscriber.yield(value) + case let .failure(error): + subscriber.yield(throwing: error) + } + } + return SharedSubscription { + cancellable.cancel() + } + #endif + } +} + +struct FetchKeyID: Hashable { + fileprivate let databaseID: ObjectIdentifier + fileprivate let request: AnyHashableSendable + fileprivate let requestTypeID: ObjectIdentifier + fileprivate let scheduler: AnyHashableSendable? + + fileprivate init( + database: any DatabaseReader, + request: some FetchKeyRequest, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + self.databaseID = ObjectIdentifier(database) + self.request = AnyHashableSendable(request) + self.requestTypeID = ObjectIdentifier(type(of: request)) + self.scheduler = scheduler.map { AnyHashableSendable($0) } + } +} + +private struct FetchAllRequest: FetchKeyRequest { + var sql: String + var arguments: StatementArguments = StatementArguments() + func fetch(_ db: Database) throws -> [Element] { + try Element.fetchAll(db, sql: sql, arguments: arguments) + } +} + +private struct FetchOneRequest: FetchKeyRequest { + var sql: String + var arguments: StatementArguments = StatementArguments() + func fetch(_ db: Database) throws -> Value { + guard let value = try Value.fetchOne(db, sql: sql, arguments: arguments) + else { throw NotFound() } + return value + } +} + +public struct NotFound: Error { + public init() {} +} + +private struct ImmediateScheduler: ValueObservationScheduler, Hashable { + func immediateInitialValue() -> Bool { true } + func schedule(_ action: @escaping @Sendable () -> Void) { + action() + } +} diff --git a/Sources/SQLiteData/SQLiteDataCore/FetchKey+SwiftUI.swift b/Sources/SQLiteData/SQLiteDataCore/FetchKey+SwiftUI.swift deleted file mode 100644 index 9ca42d0d..00000000 --- a/Sources/SQLiteData/SQLiteDataCore/FetchKey+SwiftUI.swift +++ /dev/null @@ -1,138 +0,0 @@ -#if canImport(SwiftUI) - import GRDB - import Sharing - import SwiftUI - - extension SharedReaderKey { - /// A key that can query for data in a SQLite database. - /// - /// A version of `fetch` that can be configured with a SwiftUI animation. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use the default database. - /// - animation: The animation to use for user interface changes that result from changes to - /// the fetched results. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey { - .fetch(request, database: database, scheduler: .animation(animation)) - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of `fetch` that can be configured with a SwiftUI animation. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use the default database. - /// - animation: The animation to use for user interface changes that result from changes to - /// the fetched results. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey.Default { - .fetch(request, database: database, scheduler: .animation(animation)) - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of `fetchAll` that can be configured with a SwiftUI animation. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use the default database. - /// - animation: The animation to use for user interface changes that result from changes to - /// the fetched results. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public static func fetchAll( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey<[Record]>.Default { - .fetchAll( - sql: sql, - arguments: arguments, - database: database, - scheduler: .animation(animation) - ) - } - - /// A key that can query for a value in a SQLite database. - /// - /// A version of `fetchOne` that can be configured with a SwiftUI animation. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use the default database. - /// - animation: The animation to use for user interface changes that result from changes to - /// the fetched results. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public static func fetchOne( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey { - .fetchOne( - sql: sql, - arguments: arguments, - database: database, - scheduler: .animation(animation) - ) - } - } - -package struct AnimatedScheduler: ValueObservationScheduler, Equatable { - let animation: Animation - package func immediateInitialValue() -> Bool { true } - package func schedule(_ action: @escaping @Sendable () -> Void) { - DispatchQueue.main.async { - withAnimation(animation) { - action() - } - } - } - } - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension AnimatedScheduler: Hashable {} - - extension ValueObservationScheduler where Self == AnimatedScheduler { - package static func animation(_ animation: Animation) -> Self { - AnimatedScheduler(animation: animation) - } - } -#endif diff --git a/Sources/SQLiteData/SQLiteDataCore/FetchKey.swift b/Sources/SQLiteData/SQLiteDataCore/FetchKey.swift deleted file mode 100644 index 96cef7d4..00000000 --- a/Sources/SQLiteData/SQLiteDataCore/FetchKey.swift +++ /dev/null @@ -1,434 +0,0 @@ -import Dependencies -import Dispatch -import Foundation -import GRDB -import Sharing - -#if canImport(Combine) - @preconcurrency import Combine -#endif - -extension SharedReaderKey { - /// A key that can query for data in a SQLite database. - /// - /// This key takes a ``FetchKeyRequest`` conformance, which you define yourself. It has a single - /// requirement that describes fetching a value from a database connection. For examples, we can - /// define an `Items` request that uses GRDB's query builder to fetch some items: - /// - /// ```swift - /// struct Items: FetchKeyRequest { - /// func fetch(_ db: Database) throws -> [Item] { - /// try Item.all - /// .order { $0.timestamp.desc() } - /// .fetchAll(db) - /// } - /// } - /// ``` - /// - /// And one can query for this data by wrapping the request in this key and provide it to the - /// `@SharedReader` property wrapper: - /// - /// ```swift - /// @SharedReader(.fetch(Items()) var items - /// ``` - /// - /// For simpler querying needs, you can skip the ceremony of defining a ``FetchKeyRequest`` and - /// use a raw SQL query with ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` or - /// ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)``, instead. - /// - /// To animate or observe changes with a custom scheduler, see - /// ``Sharing/SharedReaderKey/fetch(_:database:animation:)`` or - /// ``Sharing/SharedReaderKey/fetch(_:database:scheduler:)``. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey { - FetchKey(request: request, database: database, scheduler: nil) - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of `fetch` that allows you to omit the type and default from the `@SharedReader` - /// property wrapper: - /// - /// ```diff - /// -@SharedReader(.fetch(Items()) var items: [Item] = [] - /// +@SharedReader(.fetch(Items()) var items - /// ``` - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey.Default { - Self[.fetch(request, database: database), default: Value()] - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// This key gives you the ability to fetch and observe the results of a raw SQL query decoded to - /// some `GRDB.FetchableRecord` type: - /// - /// ```swift - /// @SharedReader(.fetchAll(sql: "SELECT * FROM items")) var items: [Item] - /// ``` - /// - /// For more complex querying needs, see ``Sharing/SharedReaderKey/fetch(_:database:)``. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - public static func fetchAll( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey<[Record]>.Default { - Self[ - .fetch(FetchAllRequest(sql: sql, arguments: arguments), database: database), - default: [] - ] - } - - /// A key that can query for a value in a SQLite database. - /// - /// This key gives you the ability to fetch and observe the result of a raw SQL query converted to - /// some `GRDB.DatabaseValueConvertible` type: - /// - /// ```swift - /// @SharedReader(.fetchOne(sql: "SELECT count(*) FROM items")) var itemsCount = 0 - /// ``` - /// - /// For more complex querying needs, see ``Sharing/SharedReaderKey/fetch(_:database:)``. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - public static func fetchOne( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey { - .fetch(FetchOneRequest(sql: sql, arguments: arguments), database: database) - } -} - -extension SharedReaderKey { - /// A key that can query for data in a SQLite database. - /// - /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)`` that can be configured with a - /// scheduler. See ``Sharing/SharedReaderKey/fetch(_:database:)`` for more info on how to use this - /// API. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - scheduler: The scheduler to observe from. By default, database observation is performed - /// asynchronously on the main queue. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey { - FetchKey(request: request, database: database, scheduler: scheduler) - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)`` that can be configured with a - /// scheduler. See ``Sharing/SharedReaderKey/fetch(_:database:)`` for more info on how to use this - /// API. - /// - /// - Parameters: - /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - scheduler: The scheduler to observe from. By default, database observation is performed - /// asynchronously on the main queue. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") - public static func fetch( - _ request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey.Default { - Self[.fetch(request, database: database, scheduler: scheduler), default: Value()] - } - - /// A key that can query for a collection of data in a SQLite database. - /// - /// A version of ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` that can be - /// configured with a scheduler. See ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` - /// for more info on how to use this API. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - scheduler: The scheduler to observe from. By default, database observation is performed - /// asynchronously on the main queue. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") - public static func fetchAll( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey<[Record]>.Default { - Self[ - .fetch( - FetchAllRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler - ), - default: [] - ] - } - - /// A key that can query for a value in a SQLite database. - /// - /// A version of ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)`` that can be - /// configured with a scheduler. See ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)`` - /// for more info on how to use this API. - /// - /// - Parameters: - /// - sql: A raw SQL string describing the data to fetch. - /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use - /// `@Dependency(\.defaultDatabase)`. - /// - scheduler: The scheduler to observe from. By default, database observation is performed - /// asynchronously on the main queue. - /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. - @available(iOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(macOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(tvOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - @available(watchOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") - public static func fetchOne( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey { - .fetch( - FetchOneRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler - ) - } -} - -/// A type defining a reader of GRDB queries. -/// -/// You typically do not refer to this type directly, and will use -/// [`fetchAll`](), -/// [`fetchOne`](), and -/// [`fetch`]() to create instances, instead. -public struct FetchKey: SharedReaderKey { - let database: any DatabaseReader - let request: any FetchKeyRequest - let scheduler: (any ValueObservationScheduler & Hashable)? - #if DEBUG - let isDefaultDatabase: Bool - #endif - - public typealias ID = FetchKeyID - - public var id: ID { - ID(database: database, request: request, scheduler: scheduler) - } - - init( - request: some FetchKeyRequest, - database: (any DatabaseReader)? = nil, - scheduler: (any ValueObservationScheduler & Hashable)? - ) { - @Dependency(\.defaultDatabase) var defaultDatabase - self.scheduler = scheduler - self.database = database ?? defaultDatabase - self.request = request - #if DEBUG - self.isDefaultDatabase = self.database.configuration.label == .defaultDatabaseLabel - #endif - } - - public func load(context: LoadContext, continuation: LoadContinuation) { - #if DEBUG - guard !isDefaultDatabase else { - continuation.resumeReturningInitialValue() - return - } - #endif - guard case .userInitiated = context else { - continuation.resumeReturningInitialValue() - return - } - let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler() - database.asyncRead { dbResult in - let result = dbResult.flatMap { db in - Result { - try request.fetch(db) - } - } - scheduler.schedule { - switch result { - case let .success(value): - continuation.resume(returning: value) - case let .failure(error): - continuation.resume(throwing: error) - } - } - } - } - - public func subscribe( - context: LoadContext, subscriber: SharedSubscriber - ) -> SharedSubscription { - #if DEBUG - guard !isDefaultDatabase else { - return SharedSubscription {} - } - #endif - let observation = ValueObservation.tracking { db in - Result { try request.fetch(db) } - } - - let scheduler: any ValueObservationScheduler = scheduler ?? ImmediateScheduler() - #if canImport(Combine) - let dropFirst = - switch context { - case .initialValue: false - case .userInitiated: true - } - let cancellable = observation.publisher(in: database, scheduling: scheduler) - .dropFirst(dropFirst ? 1 : 0) - .sink { completion in - switch completion { - case let .failure(error): - subscriber.yield(throwing: error) - case .finished: - break - } - } receiveValue: { newValue in - switch newValue { - case let .success(value): - subscriber.yield(value) - case let .failure(error): - subscriber.yield(throwing: error) - } - } - return SharedSubscription { - cancellable.cancel() - } - #else - let cancellable = observation.start(in: database, scheduling: scheduler) { error in - subscriber.yield(throwing: error) - } onChange: { newValue in - switch newValue { - case let .success(value): - subscriber.yield(value) - case let .failure(error): - subscriber.yield(throwing: error) - } - } - return SharedSubscription { - cancellable.cancel() - } - #endif - } -} - -/// A value that uniquely identifies a fetch key. -public struct FetchKeyID: Hashable { - fileprivate let databaseID: ObjectIdentifier - fileprivate let request: AnyHashableSendable - fileprivate let requestTypeID: ObjectIdentifier - fileprivate let scheduler: AnyHashableSendable? - - fileprivate init( - database: any DatabaseReader, - request: some FetchKeyRequest, - scheduler: (any ValueObservationScheduler & Hashable)? - ) { - self.databaseID = ObjectIdentifier(database) - self.request = AnyHashableSendable(request) - self.requestTypeID = ObjectIdentifier(type(of: request)) - self.scheduler = scheduler.map { AnyHashableSendable($0) } - } -} - -private struct FetchAllRequest: FetchKeyRequest { - var sql: String - var arguments: StatementArguments = StatementArguments() - func fetch(_ db: Database) throws -> [Element] { - try Element.fetchAll(db, sql: sql, arguments: arguments) - } -} - -private struct FetchOneRequest: FetchKeyRequest { - var sql: String - var arguments: StatementArguments = StatementArguments() - func fetch(_ db: Database) throws -> Value { - guard let value = try Value.fetchOne(db, sql: sql, arguments: arguments) - else { throw NotFound() } - return value - } -} - -public struct NotFound: Error { - public init() {} -} - -private struct ImmediateScheduler: ValueObservationScheduler, Hashable { - func immediateInitialValue() -> Bool { true } - func schedule(_ action: @escaping @Sendable () -> Void) { - action() - } -} From ba55ab3ad1dac243712bce2da2bad944eaf9aa3a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:04:37 -0700 Subject: [PATCH 516/581] wip --- .../SQLiteData/CloudKit/CloudContainer.swift | 147 ++--- .../SQLiteData/CloudKit/CloudDatabase.swift | 213 +++---- .../CloudKit/CloudKit+StructuredQueries.swift | 536 +++++++++--------- .../SQLiteData/CloudKit/CloudKitSharing.swift | 5 +- .../IdentifierStringConvertible.swift | 6 +- .../Internal/MockCloudContainer.swift | 12 +- .../CloudKit/Internal/MockCloudDatabase.swift | 3 +- Sources/SQLiteData/CloudKit/Logging.swift | 435 +++++++------- .../SQLiteData/CloudKit/Metadatabase.swift | 253 +++++---- .../CloudKit/StateSerialization.swift | 20 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 20 +- .../CloudKit/SyncEngineProtocol+Live.swift | 26 +- .../CloudKit/SyncEngineProtocol.swift | 64 +-- Sources/SQLiteData/CloudKit/Triggers.swift | 8 +- .../CloudKit/UnsyncedRecordID.swift | 4 +- Sources/SQLiteData/Internal/DataManager.swift | 2 +- .../Internal/FetchKey+SwiftUI.swift | 4 +- .../SQLiteDataTestSupport/AssertQuery.swift | 4 +- 18 files changed, 890 insertions(+), 872 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudContainer.swift b/Sources/SQLiteData/CloudKit/CloudContainer.swift index 5627daf5..4829102f 100644 --- a/Sources/SQLiteData/CloudKit/CloudContainer.swift +++ b/Sources/SQLiteData/CloudKit/CloudContainer.swift @@ -1,89 +1,90 @@ #if canImport(CloudKit) -import CloudKit + import CloudKit -@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) -package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { - associatedtype Database: CloudDatabase + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { + associatedtype Database: CloudDatabase - func accountStatus() async throws -> CKAccountStatus - var containerIdentifier: String? { get } - var rawValue: CKContainer { get } - var privateCloudDatabase: Database { get } - func accept(_ metadata: ShareMetadata) async throws -> CKShare - static func createContainer(identifier containerIdentifier: String) -> Self - var sharedCloudDatabase: Database { get } - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - func shareMetadata(for share: CKShare, shouldFetchRootRecord: Bool) async throws -> ShareMetadata -} - -@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) -package struct ShareMetadata: Hashable { - package var containerIdentifier: String - package var hierarchicalRootRecordID: CKRecord.ID? - package var rootRecord: CKRecord? - package var share: CKShare - package var rawValue: CKShare.Metadata? - package init(rawValue: CKShare.Metadata) { - self.containerIdentifier = rawValue.containerIdentifier - self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID - self.rootRecord = rawValue.rootRecord - self.share = rawValue.share - self.rawValue = rawValue - } - package init( - containerIdentifier: String, - hierarchicalRootRecordID: CKRecord.ID?, - rootRecord: CKRecord?, - share: CKShare - ) { - self.containerIdentifier = containerIdentifier - self.hierarchicalRootRecordID = hierarchicalRootRecordID - self.rootRecord = rootRecord - self.share = share - self.rawValue = nil + func accountStatus() async throws -> CKAccountStatus + var containerIdentifier: String? { get } + var rawValue: CKContainer { get } + var privateCloudDatabase: Database { get } + func accept(_ metadata: ShareMetadata) async throws -> CKShare + static func createContainer(identifier containerIdentifier: String) -> Self + var sharedCloudDatabase: Database { get } + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func shareMetadata(for share: CKShare, shouldFetchRootRecord: Bool) async throws + -> ShareMetadata } -} -@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) -extension CloudContainer { - package func database(for recordID: CKRecord.ID) -> any CloudDatabase { - recordID.zoneID.ownerName == CKCurrentUserDefaultName - ? privateCloudDatabase - : sharedCloudDatabase + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + package struct ShareMetadata: Hashable { + package var containerIdentifier: String + package var hierarchicalRootRecordID: CKRecord.ID? + package var rootRecord: CKRecord? + package var share: CKShare + package var rawValue: CKShare.Metadata? + package init(rawValue: CKShare.Metadata) { + self.containerIdentifier = rawValue.containerIdentifier + self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID + self.rootRecord = rawValue.rootRecord + self.share = rawValue.share + self.rawValue = rawValue + } + package init( + containerIdentifier: String, + hierarchicalRootRecordID: CKRecord.ID?, + rootRecord: CKRecord?, + share: CKShare + ) { + self.containerIdentifier = containerIdentifier + self.hierarchicalRootRecordID = hierarchicalRootRecordID + self.rootRecord = rootRecord + self.share = share + self.rawValue = nil + } } -} -@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) -extension CKContainer: CloudContainer { - package func accept(_ metadata: ShareMetadata) async throws -> CKShare { - guard let metadata = metadata.rawValue - else { - fatalError("This should never be called with 'ShareMetadata' that has a nil 'rawValue'") + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + extension CloudContainer { + package func database(for recordID: CKRecord.ID) -> any CloudDatabase { + recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? privateCloudDatabase + : sharedCloudDatabase } - return try await self.accept(metadata) } - package static func createContainer(identifier containerIdentifier: String) -> Self { - Self(identifier: containerIdentifier) - } + @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) + extension CKContainer: CloudContainer { + package func accept(_ metadata: ShareMetadata) async throws -> CKShare { + guard let metadata = metadata.rawValue + else { + fatalError("This should never be called with 'ShareMetadata' that has a nil 'rawValue'") + } + return try await self.accept(metadata) + } - package var rawValue: CKContainer { - self - } + package static func createContainer(identifier containerIdentifier: String) -> Self { + Self(identifier: containerIdentifier) + } + + package var rawValue: CKContainer { + self + } - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - package func shareMetadata( - for share: CKShare, - shouldFetchRootRecord: Bool = false - ) async throws -> ShareMetadata { - try await withUnsafeThrowingContinuation { continuation in - let operation = CKFetchShareMetadataOperation(shareURLs: [share.url].compactMap(\.self)) - operation.shouldFetchRootRecord = true - operation.perShareMetadataResultBlock = { url, result in - continuation.resume(with: result.map(ShareMetadata.init(rawValue:))) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + package func shareMetadata( + for share: CKShare, + shouldFetchRootRecord: Bool = false + ) async throws -> ShareMetadata { + try await withUnsafeThrowingContinuation { continuation in + let operation = CKFetchShareMetadataOperation(shareURLs: [share.url].compactMap(\.self)) + operation.shouldFetchRootRecord = true + operation.perShareMetadataResultBlock = { url, result in + continuation.resume(with: result.map(ShareMetadata.init(rawValue:))) + } + add(operation) } - add(operation) } } -} #endif diff --git a/Sources/SQLiteData/CloudKit/CloudDatabase.swift b/Sources/SQLiteData/CloudKit/CloudDatabase.swift index ead60097..94a0dc61 100644 --- a/Sources/SQLiteData/CloudKit/CloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/CloudDatabase.swift @@ -1,122 +1,123 @@ #if canImport(CloudKit) -import CloudKit - -package protocol CloudDatabase: AnyObject, Hashable, Sendable { - var databaseScope: CKDatabase.Scope { get } - - func record(for recordID: CKRecord.ID) async throws -> CKRecord - - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - func records( - for ids: [CKRecord.ID], - desiredKeys: [CKRecord.FieldKey]? - ) async throws -> [CKRecord.ID : Result] - - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - func modifyRecords( - saving recordsToSave: [CKRecord], - deleting recordIDsToDelete: [CKRecord.ID], - savePolicy: CKModifyRecordsOperation.RecordSavePolicy, - atomically: Bool - ) async throws -> ( - saveResults: [CKRecord.ID : Result], - deleteResults: [CKRecord.ID : Result] - ) - - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - func modifyRecordZones( - saving recordZonesToSave: [CKRecordZone], - deleting recordZoneIDsToDelete: [CKRecordZone.ID] - ) async throws -> ( - saveResults: [CKRecordZone.ID : Result], - deleteResults: [CKRecordZone.ID : Result] - ) -} - -extension CloudDatabase { - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - func modifyRecords( - saving recordsToSave: [CKRecord], - deleting recordIDsToDelete: [CKRecord.ID] - ) async throws -> ( - saveResults: [CKRecord.ID : Result], - deleteResults: [CKRecord.ID : Result] - ) { - try await modifyRecords( - saving: recordsToSave, - deleting: recordIDsToDelete, - savePolicy: .ifServerRecordUnchanged, - atomically: true + import CloudKit + + package protocol CloudDatabase: AnyObject, Hashable, Sendable { + var databaseScope: CKDatabase.Scope { get } + + func record(for recordID: CKRecord.ID) async throws -> CKRecord + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func records( + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? + ) async throws -> [CKRecord.ID: Result] + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy, + atomically: Bool + ) async throws -> ( + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] ) - } - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - package func records( - for ids: [CKRecord.ID] - ) async throws -> [CKRecord.ID : Result] { - try await records(for: ids, desiredKeys: nil) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecordZones( + saving recordZonesToSave: [CKRecordZone], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] + ) async throws -> ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) } -} -extension CKDatabase: CloudDatabase {} + extension CloudDatabase { + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID] + ) async throws -> ( + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] + ) { + try await modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete, + savePolicy: .ifServerRecordUnchanged, + atomically: true + ) + } -final class AnyCloudDatabase: CloudDatabase { - let rawValue: any CloudDatabase - init(_ rawValue: any CloudDatabase) { - self.rawValue = rawValue + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + package func records( + for ids: [CKRecord.ID] + ) async throws -> [CKRecord.ID: Result] { + try await records(for: ids, desiredKeys: nil) + } } - var databaseScope: CKDatabase.Scope { - rawValue.databaseScope - } + extension CKDatabase: CloudDatabase {} - func record(for recordID: CKRecord.ID) async throws -> CKRecord { - try await rawValue.record(for: recordID) - } + final class AnyCloudDatabase: CloudDatabase { + let rawValue: any CloudDatabase + init(_ rawValue: any CloudDatabase) { + self.rawValue = rawValue + } - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - func records( - for ids: [CKRecord.ID], - desiredKeys: [CKRecord.FieldKey]? - ) async throws -> [CKRecord.ID : Result] { - try await rawValue.records(for: ids) - } + var databaseScope: CKDatabase.Scope { + rawValue.databaseScope + } - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - func modifyRecords( - saving recordsToSave: [CKRecord], - deleting recordIDsToDelete: [CKRecord.ID], - savePolicy: CKModifyRecordsOperation.RecordSavePolicy, - atomically: Bool - ) async throws -> ( - saveResults: [CKRecord.ID : Result], - deleteResults: [CKRecord.ID : Result] - ) { - try await rawValue.modifyRecords( - saving: recordsToSave, - deleting: recordIDsToDelete, - savePolicy: savePolicy, - atomically: atomically - ) - } + func record(for recordID: CKRecord.ID) async throws -> CKRecord { + try await rawValue.record(for: recordID) + } - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) - func modifyRecordZones( - saving recordZonesToSave: [CKRecordZone], - deleting recordZoneIDsToDelete: [CKRecordZone.ID] - ) async throws -> ( - saveResults: [CKRecordZone.ID : Result], - deleteResults: [CKRecordZone.ID : Result] - ) { - try await rawValue.modifyRecordZones(saving: recordZonesToSave, deleting: recordZoneIDsToDelete) - } + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func records( + for ids: [CKRecord.ID], + desiredKeys: [CKRecord.FieldKey]? + ) async throws -> [CKRecord.ID: Result] { + try await rawValue.records(for: ids) + } - static func == (lhs: AnyCloudDatabase, rhs: AnyCloudDatabase) -> Bool { - lhs.rawValue === rhs.rawValue - } + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecords( + saving recordsToSave: [CKRecord], + deleting recordIDsToDelete: [CKRecord.ID], + savePolicy: CKModifyRecordsOperation.RecordSavePolicy, + atomically: Bool + ) async throws -> ( + saveResults: [CKRecord.ID: Result], + deleteResults: [CKRecord.ID: Result] + ) { + try await rawValue.modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete, + savePolicy: savePolicy, + atomically: atomically + ) + } + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func modifyRecordZones( + saving recordZonesToSave: [CKRecordZone], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] + ) async throws -> ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) { + try await rawValue.modifyRecordZones( + saving: recordZonesToSave, deleting: recordZoneIDsToDelete) + } + + static func == (lhs: AnyCloudDatabase, rhs: AnyCloudDatabase) -> Bool { + lhs.rawValue === rhs.rawValue + } - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(rawValue)) + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(rawValue)) + } } -} #endif diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index c4d578ca..545efe20 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -1,341 +1,343 @@ #if canImport(CloudKit) -import CloudKit -import CryptoKit -import CustomDump -import StructuredQueriesCore - -extension _CKRecord where Self == CKRecord { - typealias AllFieldsRepresentation = _AllFieldsRepresentation - public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation -} - -extension _CKRecord where Self == CKShare { - typealias AllFieldsRepresentation = _AllFieldsRepresentation - public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation -} - -extension Optional where Wrapped: CKRecord { - package typealias AllFieldsRepresentation = _AllFieldsRepresentation? - public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? -} - -public struct _SystemFieldsRepresentation: QueryBindable, QueryRepresentable { - public let queryOutput: Record - - public var queryBinding: QueryBinding { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - queryOutput.encodeSystemFields(with: archiver) - if isTesting { - archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") - } - return archiver.encodedData.queryBinding + import CloudKit + import CryptoKit + import CustomDump + import StructuredQueriesCore + + extension _CKRecord where Self == CKRecord { + typealias AllFieldsRepresentation = _AllFieldsRepresentation + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } - public init(queryOutput: Record) { - self.queryOutput = queryOutput + extension _CKRecord where Self == CKShare { + typealias AllFieldsRepresentation = _AllFieldsRepresentation + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } - public init?(queryBinding: QueryBinding) { - guard case .blob(let bytes) = queryBinding else { return nil } - try? self.init(data: Data(bytes)) + extension Optional where Wrapped: CKRecord { + package typealias AllFieldsRepresentation = _AllFieldsRepresentation? + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? } - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - try self.init(data: try Data(decoder: &decoder)) - } + public struct _SystemFieldsRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: Record - private init(data: Data) throws { - let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true - guard let queryOutput = Record(coder: coder) else { - throw DecodingError() - } - if isTesting { - queryOutput._recordChangeTag = coder - .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String + public var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encodeSystemFields(with: archiver) + if isTesting { + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + } + return archiver.encodedData.queryBinding } - self.init(queryOutput: queryOutput) - } - private struct DecodingError: Error {} -} + public init(queryOutput: Record) { + self.queryOutput = queryOutput + } -package struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { - package let queryOutput: Record + public init?(queryBinding: QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) + } - package var queryBinding: QueryBinding { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - queryOutput.encode(with: archiver) - if isTesting { - archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + try self.init(data: try Data(decoder: &decoder)) } - return archiver.encodedData.queryBinding - } - package init(queryOutput: Record) { - self.queryOutput = queryOutput - } + private init(data: Data) throws { + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = Record(coder: coder) else { + throw DecodingError() + } + if isTesting { + queryOutput._recordChangeTag = + coder + .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String + } + self.init(queryOutput: queryOutput) + } - package init?(queryBinding: QueryBinding) { - guard case .blob(let bytes) = queryBinding else { return nil } - try? self.init(data: Data(bytes)) + private struct DecodingError: Error {} } - package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - try self.init(data: try Data(decoder: &decoder)) - } + package struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { + package let queryOutput: Record - private init(data: Data) throws { - let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true - guard let queryOutput = Record(coder: coder) else { - throw DecodingError() - } - if isTesting { - queryOutput._recordChangeTag = coder - .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String + package var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encode(with: archiver) + if isTesting { + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + } + return archiver.encodedData.queryBinding } - self.init(queryOutput: queryOutput) - } - - private struct DecodingError: Error {} -} - -extension CKRecord: _CKRecord {} -public protocol _CKRecord {} - -extension CKDatabase.Scope { - public struct RawValueRepresentation: QueryBindable, QueryRepresentable { - public let queryOutput: CKDatabase.Scope - public var queryBinding: QueryBinding { - queryOutput.rawValue.queryBinding - } - public init(queryOutput: CKDatabase.Scope) { + package init(queryOutput: Record) { self.queryOutput = queryOutput } - public init?(queryBinding: QueryBinding) { - guard case .int(let rawValue) = queryBinding else { return nil } - try? self.init(rawValue: Int(rawValue)) + + package init?(queryBinding: QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) } - public init(decoder: inout some QueryDecoder) throws { - try self.init(rawValue: Int(decoder: &decoder)) + + package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + try self.init(data: try Data(decoder: &decoder)) } - private init(rawValue: Int) throws { - guard let queryOutput = CKDatabase.Scope(rawValue: rawValue) else { + + private init(data: Data) throws { + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = Record(coder: coder) else { throw DecodingError() } + if isTesting { + queryOutput._recordChangeTag = + coder + .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String + } self.init(queryOutput: queryOutput) } + private struct DecodingError: Error {} } -} -@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -extension CKRecordKeyValueSetting { - subscript(at key: String) -> Date { - get { - self["\(CKRecord.userModificationDateKey)_\(key)"] as? Date ?? .distantPast - } - set { - self["\(CKRecord.userModificationDateKey)_\(key)"] = max(self[at: key], newValue) + extension CKRecord: _CKRecord {} + + public protocol _CKRecord {} + + extension CKDatabase.Scope { + public struct RawValueRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: CKDatabase.Scope + public var queryBinding: QueryBinding { + queryOutput.rawValue.queryBinding + } + public init(queryOutput: CKDatabase.Scope) { + self.queryOutput = queryOutput + } + public init?(queryBinding: QueryBinding) { + guard case .int(let rawValue) = queryBinding else { return nil } + try? self.init(rawValue: Int(rawValue)) + } + public init(decoder: inout some QueryDecoder) throws { + try self.init(rawValue: Int(decoder: &decoder)) + } + private init(rawValue: Int) throws { + guard let queryOutput = CKDatabase.Scope(rawValue: rawValue) else { + throw DecodingError() + } + self.init(queryOutput: queryOutput) + } + private struct DecodingError: Error {} } } -} - -@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -extension URL { - init(hash data: some DataProtocol) { - @Dependency(\.dataManager) var dataManager - let hash = SHA256.hash(data: data).compactMap { String(format: "%02hhx", $0) }.joined() - self = dataManager.temporaryDirectory.appendingPathComponent(hash) - } -} - -@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -extension CKRecord { - @discardableResult - package func setValue( - _ newValue: some CKRecordValueProtocol & Equatable, - forKey key: CKRecord.FieldKey, - at userModificationDate: Date - ) -> Bool { - guard - encryptedValues[at: key] < userModificationDate, - encryptedValues[key] != newValue - else { return false } - encryptedValues[key] = newValue - encryptedValues[at: key] = userModificationDate - self.userModificationDate = userModificationDate - return true + + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + extension CKRecordKeyValueSetting { + subscript(at key: String) -> Date { + get { + self["\(CKRecord.userModificationDateKey)_\(key)"] as? Date ?? .distantPast + } + set { + self["\(CKRecord.userModificationDateKey)_\(key)"] = max(self[at: key], newValue) + } + } } - @discardableResult - package func setValue( - _ newValue: [UInt8], - forKey key: CKRecord.FieldKey, - at userModificationDate: Date - ) -> Bool { - @Dependency(\.dataManager) var dataManager - - guard encryptedValues[at: key] < userModificationDate else { return false } - - let asset = CKAsset(fileURL: URL(hash: newValue)) - guard let fileURL = asset.fileURL, (self[key] as? CKAsset)?.fileURL != fileURL - else { return false } - withErrorReporting(.sqliteDataCloudKitFailure) { - try dataManager.save(Data(newValue), to: fileURL) + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + extension URL { + init(hash data: some DataProtocol) { + @Dependency(\.dataManager) var dataManager + let hash = SHA256.hash(data: data).compactMap { String(format: "%02hhx", $0) }.joined() + self = dataManager.temporaryDirectory.appendingPathComponent(hash) } - self[key] = asset - encryptedValues[at: key] = userModificationDate - self.userModificationDate = userModificationDate - return true } - @discardableResult - package func removeValue( - forKey key: CKRecord.FieldKey, - at userModificationDate: Date - ) -> Bool { - guard encryptedValues[at: key] < userModificationDate - else { return false } - if encryptedValues[key] != nil { - encryptedValues[key] = nil + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + extension CKRecord { + @discardableResult + package func setValue( + _ newValue: some CKRecordValueProtocol & Equatable, + forKey key: CKRecord.FieldKey, + at userModificationDate: Date + ) -> Bool { + guard + encryptedValues[at: key] < userModificationDate, + encryptedValues[key] != newValue + else { return false } + encryptedValues[key] = newValue encryptedValues[at: key] = userModificationDate self.userModificationDate = userModificationDate return true - } else if self[key] != nil { - self[key] = nil + } + + @discardableResult + package func setValue( + _ newValue: [UInt8], + forKey key: CKRecord.FieldKey, + at userModificationDate: Date + ) -> Bool { + @Dependency(\.dataManager) var dataManager + + guard encryptedValues[at: key] < userModificationDate else { return false } + + let asset = CKAsset(fileURL: URL(hash: newValue)) + guard let fileURL = asset.fileURL, (self[key] as? CKAsset)?.fileURL != fileURL + else { return false } + withErrorReporting(.sqliteDataCloudKitFailure) { + try dataManager.save(Data(newValue), to: fileURL) + } + self[key] = asset encryptedValues[at: key] = userModificationDate self.userModificationDate = userModificationDate return true } - return false - } - func update(with row: T, userModificationDate: Date) { - for column in T.TableColumns.writableColumns { - func open(_ column: some WritableTableColumnExpression) { - let column = column as! any WritableTableColumnExpression - let value = Value(queryOutput: row[keyPath: column.keyPath]) - switch value.queryBinding { - case .blob(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .double(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .date(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .int(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .null: - removeValue(forKey: column.name, at: userModificationDate) - case .text(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .uuid(let value): - setValue( - value.uuidString.lowercased(), - forKey: column.name, - at: userModificationDate - ) - case .invalid(let error): - reportIssue(error) - } + @discardableResult + package func removeValue( + forKey key: CKRecord.FieldKey, + at userModificationDate: Date + ) -> Bool { + guard encryptedValues[at: key] < userModificationDate + else { return false } + if encryptedValues[key] != nil { + encryptedValues[key] = nil + encryptedValues[at: key] = userModificationDate + self.userModificationDate = userModificationDate + return true + } else if self[key] != nil { + self[key] = nil + encryptedValues[at: key] = userModificationDate + self.userModificationDate = userModificationDate + return true } - open(column) + return false } - } - func update( - with other: CKRecord, - row: T, - columnNames: inout [String], - parentForeignKey: ForeignKey? - ) { - typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable - - self.userModificationDate = other.userModificationDate - for column in T.TableColumns.writableColumns { - func open(_ column: some WritableTableColumnExpression) { - let key = column.name - let column = column as! any WritableTableColumnExpression - let didSet: Bool - if let value = other[key] as? CKAsset { - didSet = setValue(value, forKey: key, at: other[at: key]) - } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { - didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) - } else if other.encryptedValues[key] == nil { - didSet = removeValue(forKey: key, at: other.encryptedValues[at: key]) - } else { - didSet = false - } - /// The row value has been modified more recently than the last known record. - var isRowValueModified: Bool { - switch Value(queryOutput: row[keyPath: column.keyPath]).queryBinding { + func update(with row: T, userModificationDate: Date) { + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let column = column as! any WritableTableColumnExpression + let value = Value(queryOutput: row[keyPath: column.keyPath]) + switch value.queryBinding { case .blob(let value): - return (other[key] as? CKAsset)?.fileURL != URL(hash: value) + setValue(value, forKey: column.name, at: userModificationDate) case .double(let value): - return other.encryptedValues[key] != value + setValue(value, forKey: column.name, at: userModificationDate) case .date(let value): - return other.encryptedValues[key] != value + setValue(value, forKey: column.name, at: userModificationDate) case .int(let value): - return other.encryptedValues[key] != value + setValue(value, forKey: column.name, at: userModificationDate) case .null: - return other.encryptedValues[key] != nil + removeValue(forKey: column.name, at: userModificationDate) case .text(let value): - return other.encryptedValues[key] != value + setValue(value, forKey: column.name, at: userModificationDate) case .uuid(let value): - return other.encryptedValues[key] != value.uuidString.lowercased() + setValue( + value.uuidString.lowercased(), + forKey: column.name, + at: userModificationDate + ) case .invalid(let error): reportIssue(error) - return false } } - if didSet || isRowValueModified { - columnNames.removeAll(where: { $0 == key }) - if didSet, let parentForeignKey, key == parentForeignKey.from { - self.parent = other.parent + open(column) + } + } + + func update( + with other: CKRecord, + row: T, + columnNames: inout [String], + parentForeignKey: ForeignKey? + ) { + typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable + + self.userModificationDate = other.userModificationDate + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let key = column.name + let column = column as! any WritableTableColumnExpression + let didSet: Bool + if let value = other[key] as? CKAsset { + didSet = setValue(value, forKey: key, at: other[at: key]) + } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { + didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) + } else if other.encryptedValues[key] == nil { + didSet = removeValue(forKey: key, at: other.encryptedValues[at: key]) + } else { + didSet = false + } + /// The row value has been modified more recently than the last known record. + var isRowValueModified: Bool { + switch Value(queryOutput: row[keyPath: column.keyPath]).queryBinding { + case .blob(let value): + return (other[key] as? CKAsset)?.fileURL != URL(hash: value) + case .double(let value): + return other.encryptedValues[key] != value + case .date(let value): + return other.encryptedValues[key] != value + case .int(let value): + return other.encryptedValues[key] != value + case .null: + return other.encryptedValues[key] != nil + case .text(let value): + return other.encryptedValues[key] != value + case .uuid(let value): + return other.encryptedValues[key] != value.uuidString.lowercased() + case .invalid(let error): + reportIssue(error) + return false + } + } + if didSet || isRowValueModified { + columnNames.removeAll(where: { $0 == key }) + if didSet, let parentForeignKey, key == parentForeignKey.from { + self.parent = other.parent + } } } + open(column) } - open(column) } - } - package var userModificationDate: Date { - get { encryptedValues[Self.userModificationDateKey] as? Date ?? .distantPast } - set { - encryptedValues[Self.userModificationDateKey] = Swift.max(userModificationDate, newValue) + package var userModificationDate: Date { + get { encryptedValues[Self.userModificationDateKey] as? Date ?? .distantPast } + set { + encryptedValues[Self.userModificationDateKey] = Swift.max(userModificationDate, newValue) + } } + + package static let userModificationDateKey = + "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" } - package static let userModificationDateKey = - "\(String.sqliteDataCloudKitSchemaName)_userModificationDate" -} - -extension __CKRecordObjCValue { - var queryFragment: QueryFragment { - if let value = self as? Int64 { - return value.queryFragment - } else if let value = self as? Double { - return value.queryFragment - } else if let value = self as? String { - return value.queryFragment - } else if let value = self as? Data { - return value.queryFragment - } else if let value = self as? Date { - return value.queryFragment - } else { - return "\(.invalid(Unbindable()))" + extension __CKRecordObjCValue { + var queryFragment: QueryFragment { + if let value = self as? Int64 { + return value.queryFragment + } else if let value = self as? Double { + return value.queryFragment + } else if let value = self as? String { + return value.queryFragment + } else if let value = self as? Data { + return value.queryFragment + } else if let value = self as? Date { + return value.queryFragment + } else { + return "\(.invalid(Unbindable()))" + } } } -} -private struct Unbindable: Error {} + private struct Unbindable: Error {} -extension CKRecord { - package var _recordChangeTag: String? { - get { self[#function] } - set { self[#function] = newValue } + extension CKRecord { + package var _recordChangeTag: String? { + get { self[#function] } + set { self[#function] = newValue } + } } -} #endif diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 4a945c38..d1cc32f7 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -160,7 +160,8 @@ } guard let share else { - reportIssue(""" + reportIssue( + """ No share found associated with record. """) return @@ -190,7 +191,7 @@ sharedRecord: SharedRecord, availablePermissions: UICloudSharingController.PermissionOptions = [], didFinish: @escaping (Result) -> Void = { _ in }, - didStopSharing: @escaping () -> Void = { }, + didStopSharing: @escaping () -> Void = {}, syncEngine: SyncEngine = { @Dependency(\.defaultSyncEngine) var defaultSyncEngine return defaultSyncEngine diff --git a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift index e67d8056..b7b2639b 100644 --- a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift +++ b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift @@ -20,11 +20,11 @@ extension Character: IdentifierStringConvertible {} extension Double: IdentifierStringConvertible {} extension Float: IdentifierStringConvertible {} #if !(arch(i386) || arch(x86_64)) -@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) -extension Float16: IdentifierStringConvertible {} + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension Float16: IdentifierStringConvertible {} #endif #if !(os(Windows) || os(Android) || ($Embedded && !os(Linux) && !(os(macOS) || os(iOS) || os(watchOS) || os(tvOS)))) && (arch(i386) || arch(x86_64)) -extension Float80: IdentifierStringConvertible {} + extension Float80: IdentifierStringConvertible {} #endif extension Int: IdentifierStringConvertible {} @available(iOS 18, macOS 15, tvOS 18, watchOS 11, *) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift index 39c11fe5..4b75e98c 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift @@ -1,5 +1,5 @@ -import CustomDump import CloudKit +import CustomDump @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { @@ -42,9 +42,10 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { for share: CKShare, shouldFetchRootRecord: Bool ) async throws -> ShareMetadata { - let database = share.recordID.zoneID.ownerName == CKCurrentUserDefaultName - ? privateCloudDatabase - : sharedCloudDatabase + let database = + share.recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? privateCloudDatabase + : sharedCloudDatabase let rootRecord: CKRecord? = database.storage.withValue { $0[share.recordID.zoneID]?.values.first { record in @@ -73,7 +74,8 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { return metadata.share } - package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { + package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer + { @Dependency(\.mockCloudContainers) var mockCloudContainers return mockCloudContainers.withValue { storage in let container: MockCloudContainer diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 74388699..6232cf7f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -142,8 +142,7 @@ package final class MockCloudDatabase: CloudDatabase { let rootRecord = root(of: recordToSave) let share = share(for: rootRecord) let isSavingShare = recordsToSave.contains { $0.recordID == share?.recordID } - if - !isSavingShare, + if !isSavingShare, !(recordToSave is CKShare), let share, !(share.publicPermission == .readWrite diff --git a/Sources/SQLiteData/CloudKit/Logging.swift b/Sources/SQLiteData/CloudKit/Logging.swift index b62caf4c..0e54dd96 100644 --- a/Sources/SQLiteData/CloudKit/Logging.swift +++ b/Sources/SQLiteData/CloudKit/Logging.swift @@ -1,244 +1,247 @@ #if canImport(CloudKit) -import CloudKit -import os + import CloudKit + import os -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension Logger { - func log(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) { - let prefix = "[\(syncEngine.database.databaseScope.label)] handleEvent:" - switch event { - case .stateUpdate: - debug("\(prefix) stateUpdate") - case .accountChange(let changeType): - switch changeType { - case .signIn(let currentUser): + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension Logger { + func log(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) { + let prefix = "[\(syncEngine.database.databaseScope.label)] handleEvent:" + switch event { + case .stateUpdate: + debug("\(prefix) stateUpdate") + case .accountChange(let changeType): + switch changeType { + case .signIn(let currentUser): + debug( + """ + \(prefix) signIn + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + case .signOut(let previousUser): + debug( + """ + \(prefix) signOut + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + """ + ) + case .switchAccounts(let previousUser, let currentUser): + debug( + """ + \(prefix) switchAccounts: + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + @unknown default: + debug("unknown") + } + case .fetchedDatabaseChanges(_, let deletions): + let deletions = + deletions.isEmpty + ? "⚪️ No deletions" + : "✅ Zones deleted (\(deletions.count)): " + + deletions + .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } + .sorted() + .joined(separator: ", ") debug( """ - \(prefix) signIn - Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + \(prefix) fetchedDatabaseChanges + \(deletions) """ ) - case .signOut(let previousUser): - debug( - """ - \(prefix) signOut - Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) - """ + case .fetchedRecordZoneChanges(let modifications, let deletions): + let deletionsByRecordType = Dictionary( + grouping: deletions, + by: \.recordType + ) + let recordTypeDeletions = deletionsByRecordType.keys.sorted() + .map { recordType in "\(recordType) (\(deletionsByRecordType[recordType]!.count))" } + .joined(separator: ", ") + let deletions = + deletions.isEmpty + ? "⚪️ No deletions" : "✅ Records deleted (\(deletions.count)): \(recordTypeDeletions)" + + let modificationsByRecordType = Dictionary( + grouping: modifications, + by: \.recordType ) - case .switchAccounts(let previousUser, let currentUser): + let recordTypeModifications = modificationsByRecordType.keys.sorted() + .map { recordType in "\(recordType) (\(modificationsByRecordType[recordType]!.count))" } + .joined(separator: ", ") + let modifications = + modifications.isEmpty + ? "⚪️ No modifications" + : "✅ Records modified (\(modifications.count)): \(recordTypeModifications)" + debug( """ - \(prefix) switchAccounts: - Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) - Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + \(prefix) fetchedRecordZoneChanges + \(modifications) + \(deletions) """ ) - @unknown default: - debug("unknown") - } - case .fetchedDatabaseChanges(_, let deletions): - let deletions = - deletions.isEmpty - ? "⚪️ No deletions" - : "✅ Zones deleted (\(deletions.count)): " - + deletions + case .sentDatabaseChanges( + let savedZones, + let failedZoneSaves, + let deletedZoneIDs, + let failedZoneDeletes + ): + let savedZoneNames = + savedZones .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } .sorted() .joined(separator: ", ") - debug( - """ - \(prefix) fetchedDatabaseChanges - \(deletions) - """ - ) - case .fetchedRecordZoneChanges(let modifications, let deletions): - let deletionsByRecordType = Dictionary( - grouping: deletions, - by: \.recordType - ) - let recordTypeDeletions = deletionsByRecordType.keys.sorted() - .map { recordType in "\(recordType) (\(deletionsByRecordType[recordType]!.count))" } - .joined(separator: ", ") - let deletions = - deletions.isEmpty - ? "⚪️ No deletions" : "✅ Records deleted (\(deletions.count)): \(recordTypeDeletions)" - - let modificationsByRecordType = Dictionary( - grouping: modifications, - by: \.recordType - ) - let recordTypeModifications = modificationsByRecordType.keys.sorted() - .map { recordType in "\(recordType) (\(modificationsByRecordType[recordType]!.count))" } - .joined(separator: ", ") - let modifications = - modifications.isEmpty - ? "⚪️ No modifications" - : "✅ Records modified (\(modifications.count)): \(recordTypeModifications)" - - debug( - """ - \(prefix) fetchedRecordZoneChanges - \(modifications) - \(deletions) - """ - ) - case .sentDatabaseChanges( - let savedZones, - let failedZoneSaves, - let deletedZoneIDs, - let failedZoneDeletes - ): - let savedZoneNames = savedZones - .map { $0.zoneID.zoneName + ":" + $0.zoneID.ownerName } - .sorted() - .joined(separator: ", ") - let savedZones = - savedZones.isEmpty - ? "⚪️ No saved zones" : "✅ Saved zones (\(savedZones.count)): \(savedZoneNames)" + let savedZones = + savedZones.isEmpty + ? "⚪️ No saved zones" : "✅ Saved zones (\(savedZones.count)): \(savedZoneNames)" - let deletedZoneNames = deletedZoneIDs - .map { $0.zoneName } - .sorted() - .joined(separator: ", ") - let deletedZones = - deletedZoneIDs.isEmpty - ? "⚪️ No deleted zones" - : "✅ Deleted zones (\(deletedZoneIDs.count)): \(deletedZoneNames)" + let deletedZoneNames = + deletedZoneIDs + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let deletedZones = + deletedZoneIDs.isEmpty + ? "⚪️ No deleted zones" + : "✅ Deleted zones (\(deletedZoneIDs.count)): \(deletedZoneNames)" - let failedZoneSaveNames = failedZoneSaves - .map { $0.zone.zoneID.zoneName + ":" + $0.zone.zoneID.ownerName } - .sorted() - .joined(separator: ", ") - let failedZoneSaves = - failedZoneSaves.isEmpty - ? "⚪️ No failed saved zones" - : "🛑 Failed zone saves (\(failedZoneSaves.count)): \(failedZoneSaveNames)" + let failedZoneSaveNames = + failedZoneSaves + .map { $0.zone.zoneID.zoneName + ":" + $0.zone.zoneID.ownerName } + .sorted() + .joined(separator: ", ") + let failedZoneSaves = + failedZoneSaves.isEmpty + ? "⚪️ No failed saved zones" + : "🛑 Failed zone saves (\(failedZoneSaves.count)): \(failedZoneSaveNames)" - let failedZoneDeleteNames = failedZoneDeletes - .keys - .map { $0.zoneName } - .sorted() - .joined(separator: ", ") - let failedZoneDeletes = - failedZoneDeletes.isEmpty - ? "⚪️ No failed deleted zones" - : "🛑 Failed zone delete (\(failedZoneDeletes.count)): \(failedZoneDeleteNames)" + let failedZoneDeleteNames = failedZoneDeletes + .keys + .map { $0.zoneName } + .sorted() + .joined(separator: ", ") + let failedZoneDeletes = + failedZoneDeletes.isEmpty + ? "⚪️ No failed deleted zones" + : "🛑 Failed zone delete (\(failedZoneDeletes.count)): \(failedZoneDeleteNames)" - debug( - """ - \(prefix) sentDatabaseChanges - \(savedZones) - \(deletedZones) - \(failedZoneSaves) - \(failedZoneDeletes) - """ - ) - case .sentRecordZoneChanges( - let savedRecords, - let failedRecordSaves, - let deletedRecordIDs, - let failedRecordDeletes - ): - let savedRecordsByRecordType = Dictionary( - grouping: savedRecords, - by: \.recordType - ) - let savedRecords = savedRecordsByRecordType.keys - .sorted() - .map { "\($0) (\(savedRecordsByRecordType[$0]!.count))" } - .joined(separator: ", ") + debug( + """ + \(prefix) sentDatabaseChanges + \(savedZones) + \(deletedZones) + \(failedZoneSaves) + \(failedZoneDeletes) + """ + ) + case .sentRecordZoneChanges( + let savedRecords, + let failedRecordSaves, + let deletedRecordIDs, + let failedRecordDeletes + ): + let savedRecordsByRecordType = Dictionary( + grouping: savedRecords, + by: \.recordType + ) + let savedRecords = savedRecordsByRecordType.keys + .sorted() + .map { "\($0) (\(savedRecordsByRecordType[$0]!.count))" } + .joined(separator: ", ") - let failedRecordSavesByZoneName = Dictionary( - grouping: failedRecordSaves, - by: { $0.record.recordID.zoneID.zoneName + ":" + $0.record.recordID.zoneID.ownerName } - ) - let failedRecordSaves = failedRecordSavesByZoneName.keys - .sorted() - .map { "\($0) (\(failedRecordSavesByZoneName[$0]!.count))" } - .joined(separator: ", ") + let failedRecordSavesByZoneName = Dictionary( + grouping: failedRecordSaves, + by: { $0.record.recordID.zoneID.zoneName + ":" + $0.record.recordID.zoneID.ownerName } + ) + let failedRecordSaves = failedRecordSavesByZoneName.keys + .sorted() + .map { "\($0) (\(failedRecordSavesByZoneName[$0]!.count))" } + .joined(separator: ", ") - debug( - """ - \(prefix) sentRecordZoneChanges - \(savedRecordsByRecordType.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") - \(deletedRecordIDs.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records (\(deletedRecordIDs.count))") - \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)") - \(failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(failedRecordDeletes.count))") - """ - ) - case .willFetchChanges: - debug("\(prefix) willFetchChanges") - case .willFetchRecordZoneChanges(let zoneID): - debug("\(prefix) willFetchRecordZoneChanges: \(zoneID.zoneName)") - case .didFetchRecordZoneChanges(let zoneID, let error): - let errorType = error.map { - switch $0.code { - case .internalError: "internalError" - case .partialFailure: "partialFailure" - case .networkUnavailable: "networkUnavailable" - case .networkFailure: "networkFailure" - case .badContainer: "badContainer" - case .serviceUnavailable: "serviceUnavailable" - case .requestRateLimited: "requestRateLimited" - case .missingEntitlement: "missingEntitlement" - case .notAuthenticated: "notAuthenticated" - case .permissionFailure: "permissionFailure" - case .unknownItem: "unknownItem" - case .invalidArguments: "invalidArguments" - case .resultsTruncated: "resultsTruncated" - case .serverRecordChanged: "serverRecordChanged" - case .serverRejectedRequest: "serverRejectedRequest" - case .assetFileNotFound: "assetFileNotFound" - case .assetFileModified: "assetFileModified" - case .incompatibleVersion: "incompatibleVersion" - case .constraintViolation: "constraintViolation" - case .operationCancelled: "operationCancelled" - case .changeTokenExpired: "changeTokenExpired" - case .batchRequestFailed: "batchRequestFailed" - case .zoneBusy: "zoneBusy" - case .badDatabase: "badDatabase" - case .quotaExceeded: "quotaExceeded" - case .zoneNotFound: "zoneNotFound" - case .limitExceeded: "limitExceeded" - case .userDeletedZone: "userDeletedZone" - case .tooManyParticipants: "tooManyParticipants" - case .alreadyShared: "alreadyShared" - case .referenceViolation: "referenceViolation" - case .managedAccountRestricted: "managedAccountRestricted" - case .participantMayNeedVerification: "participantMayNeedVerification" - case .serverResponseLost: "serverResponseLost" - case .assetNotAvailable: "assetNotAvailable" - case .accountTemporarilyUnavailable: "accountTemporarilyUnavailable" - @unknown default: "unknown" + debug( + """ + \(prefix) sentRecordZoneChanges + \(savedRecordsByRecordType.isEmpty ? "⚪️ No records saved" : "✅ Saved records: \(savedRecords)") + \(deletedRecordIDs.isEmpty ? "⚪️ No records deleted" : "✅ Deleted records (\(deletedRecordIDs.count))") + \(failedRecordSavesByZoneName.isEmpty ? "⚪️ No records failed save" : "🛑 Records failed save: \(failedRecordSaves)") + \(failedRecordDeletes.isEmpty ? "⚪️ No records failed delete" : "🛑 Records failed delete (\(failedRecordDeletes.count))") + """ + ) + case .willFetchChanges: + debug("\(prefix) willFetchChanges") + case .willFetchRecordZoneChanges(let zoneID): + debug("\(prefix) willFetchRecordZoneChanges: \(zoneID.zoneName)") + case .didFetchRecordZoneChanges(let zoneID, let error): + let errorType = error.map { + switch $0.code { + case .internalError: "internalError" + case .partialFailure: "partialFailure" + case .networkUnavailable: "networkUnavailable" + case .networkFailure: "networkFailure" + case .badContainer: "badContainer" + case .serviceUnavailable: "serviceUnavailable" + case .requestRateLimited: "requestRateLimited" + case .missingEntitlement: "missingEntitlement" + case .notAuthenticated: "notAuthenticated" + case .permissionFailure: "permissionFailure" + case .unknownItem: "unknownItem" + case .invalidArguments: "invalidArguments" + case .resultsTruncated: "resultsTruncated" + case .serverRecordChanged: "serverRecordChanged" + case .serverRejectedRequest: "serverRejectedRequest" + case .assetFileNotFound: "assetFileNotFound" + case .assetFileModified: "assetFileModified" + case .incompatibleVersion: "incompatibleVersion" + case .constraintViolation: "constraintViolation" + case .operationCancelled: "operationCancelled" + case .changeTokenExpired: "changeTokenExpired" + case .batchRequestFailed: "batchRequestFailed" + case .zoneBusy: "zoneBusy" + case .badDatabase: "badDatabase" + case .quotaExceeded: "quotaExceeded" + case .zoneNotFound: "zoneNotFound" + case .limitExceeded: "limitExceeded" + case .userDeletedZone: "userDeletedZone" + case .tooManyParticipants: "tooManyParticipants" + case .alreadyShared: "alreadyShared" + case .referenceViolation: "referenceViolation" + case .managedAccountRestricted: "managedAccountRestricted" + case .participantMayNeedVerification: "participantMayNeedVerification" + case .serverResponseLost: "serverResponseLost" + case .assetNotAvailable: "assetNotAvailable" + case .accountTemporarilyUnavailable: "accountTemporarilyUnavailable" + @unknown default: "unknown" + } } + let error = errorType.map { "\n ❌ \($0)" } ?? "" + debug( + """ + \(prefix) willFetchRecordZoneChanges + ✅ Zone: \(zoneID.zoneName):\(zoneID.ownerName)\(error) + """ + ) + case .didFetchChanges: + debug("\(prefix) didFetchChanges") + case .willSendChanges(let context): + debug("\(prefix) willSendChanges: \(context.reason.description)") + case .didSendChanges(let context): + debug("\(prefix) didSendChanges: \(context.reason.description)") + @unknown default: + warning("\(prefix) ⚠️ unknown event: \(event.description)") } - let error = errorType.map { "\n ❌ \($0)" } ?? "" - debug( - """ - \(prefix) willFetchRecordZoneChanges - ✅ Zone: \(zoneID.zoneName):\(zoneID.ownerName)\(error) - """ - ) - case .didFetchChanges: - debug("\(prefix) didFetchChanges") - case .willSendChanges(let context): - debug("\(prefix) willSendChanges: \(context.reason.description)") - case .didSendChanges(let context): - debug("\(prefix) didSendChanges: \(context.reason.description)") - @unknown default: - warning("\(prefix) ⚠️ unknown event: \(event.description)") } } -} -extension CKDatabase.Scope { - var label: String { - switch self { - case .public: "public" - case .private: "private" - case .shared: "shared" - @unknown default: "unknown" + extension CKDatabase.Scope { + var label: String { + switch self { + case .public: "public" + case .private: "private" + case .shared: "shared" + @unknown default: "unknown" + } } } -} #endif diff --git a/Sources/SQLiteData/CloudKit/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Metadatabase.swift index 62c617b7..437c2e0b 100644 --- a/Sources/SQLiteData/CloudKit/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Metadatabase.swift @@ -1,136 +1,139 @@ #if canImport(CloudKit) -import Foundation -import os + import Foundation + import os -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -func defaultMetadatabase( - logger: Logger, - url: URL -) throws -> any DatabaseReader { - var configuration = Configuration() - configuration.prepareDatabase { [logger] db in - db.trace { - logger.trace("\($0.expandedDescription)") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func defaultMetadatabase( + logger: Logger, + url: URL + ) throws -> any DatabaseReader { + var configuration = Configuration() + configuration.prepareDatabase { [logger] db in + db.trace { + logger.trace("\($0.expandedDescription)") + } } - } - logger.debug( - """ - Metadatabase connection: - open "\(url.path(percentEncoded: false))" - """ - ) - try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: true - ) - - @Dependency(\.context) var context - guard !url.isInMemory || context != .live - else { - struct InMemoryDatabase: Error {} - throw InMemoryDatabase() - } - - let metadatabase: any DatabaseWriter = if url.isInMemory { - try DatabaseQueue( - path: url.absoluteString, - configuration: configuration - ) - } else { - try DatabasePool( - path: url.path(percentEncoded: false), - configuration: configuration - ) - } - // TODO: go towards idempotent migrations instead of GRDB migrator by the end of all of this - var migrator = DatabaseMigrator() - // TODO: do we want this? - #if DEBUG - migrator.eraseDatabaseOnSchemaChange = true - #endif - migrator.registerMigration("Create Metadata Tables") { db in - try SQLQueryExpression( - """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( - "recordPrimaryKey" TEXT NOT NULL, - "recordType" TEXT NOT NULL, - "recordName" TEXT NOT NULL AS ("recordPrimaryKey" || ':' || "recordType"), - "parentRecordPrimaryKey" TEXT, - "parentRecordType" TEXT, - "parentRecordName" TEXT AS ("parentRecordPrimaryKey" || ':' || "parentRecordType"), - "lastKnownServerRecord" BLOB, - "_lastKnownServerRecordAllFields" BLOB, - "share" BLOB, - "isShared" INTEGER NOT NULL AS ("share" IS NOT NULL), - "userModificationDate" TEXT NOT NULL DEFAULT (\(.datetime())), - "_isDeleted" INTEGER NOT NULL DEFAULT 0, - - PRIMARY KEY ("recordPrimaryKey", "recordType"), - UNIQUE ("recordName") - ) STRICT - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" - ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("parentRecordName") - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" - ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("isShared") + logger.debug( """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( - "tableName" TEXT NOT NULL PRIMARY KEY, - "schema" TEXT NOT NULL, - "tableInfo" TEXT NOT NULL - ) STRICT - """ - ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( - "scope" TEXT NOT NULL PRIMARY KEY, - "data" TEXT NOT NULL - ) STRICT + Metadatabase connection: + open "\(url.path(percentEncoded: false))" """ ) - .execute(db) - try SQLQueryExpression( - """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( - "recordName" TEXT NOT NULL, - "zoneName" TEXT NOT NULL, - "ownerName" TEXT NOT NULL, - PRIMARY KEY ("recordName", "zoneName", "ownerName") - ) STRICT - """ + try FileManager.default.createDirectory( + at: .applicationSupportDirectory, + withIntermediateDirectories: true ) - .execute(db) - } - migrator.registerMigration("Create PendingRecordZoneChanges Table") { db in - try SQLQueryExpression(""" - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( - "pendingRecordZoneChange" BLOB NOT NULL - ) STRICT - """) - .execute(db) + + @Dependency(\.context) var context + guard !url.isInMemory || context != .live + else { + struct InMemoryDatabase: Error {} + throw InMemoryDatabase() + } + + let metadatabase: any DatabaseWriter = + if url.isInMemory { + try DatabaseQueue( + path: url.absoluteString, + configuration: configuration + ) + } else { + try DatabasePool( + path: url.path(percentEncoded: false), + configuration: configuration + ) + } + // TODO: go towards idempotent migrations instead of GRDB migrator by the end of all of this + var migrator = DatabaseMigrator() + // TODO: do we want this? + #if DEBUG + migrator.eraseDatabaseOnSchemaChange = true + #endif + migrator.registerMigration("Create Metadata Tables") { db in + try SQLQueryExpression( + """ + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( + "recordPrimaryKey" TEXT NOT NULL, + "recordType" TEXT NOT NULL, + "recordName" TEXT NOT NULL AS ("recordPrimaryKey" || ':' || "recordType"), + "parentRecordPrimaryKey" TEXT, + "parentRecordType" TEXT, + "parentRecordName" TEXT AS ("parentRecordPrimaryKey" || ':' || "parentRecordType"), + "lastKnownServerRecord" BLOB, + "_lastKnownServerRecordAllFields" BLOB, + "share" BLOB, + "isShared" INTEGER NOT NULL AS ("share" IS NOT NULL), + "userModificationDate" TEXT NOT NULL DEFAULT (\(.datetime())), + "_isDeleted" INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY ("recordPrimaryKey", "recordType"), + UNIQUE ("recordName") + ) STRICT + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("parentRecordName") + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("isShared") + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( + "tableName" TEXT NOT NULL PRIMARY KEY, + "schema" TEXT NOT NULL, + "tableInfo" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( + "scope" TEXT NOT NULL PRIMARY KEY, + "data" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try SQLQueryExpression( + """ + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( + "recordName" TEXT NOT NULL, + "zoneName" TEXT NOT NULL, + "ownerName" TEXT NOT NULL, + PRIMARY KEY ("recordName", "zoneName", "ownerName") + ) STRICT + """ + ) + .execute(db) + } + migrator.registerMigration("Create PendingRecordZoneChanges Table") { db in + try SQLQueryExpression( + """ + CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( + "pendingRecordZoneChange" BLOB NOT NULL + ) STRICT + """ + ) + .execute(db) + } + try migrator.migrate(metadatabase) + return metadatabase } - try migrator.migrate(metadatabase) - return metadatabase -} -extension QueryFragment { - static func datetime() -> Self { - Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") + extension QueryFragment { + static func datetime() -> Self { + Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") + } } -} #endif diff --git a/Sources/SQLiteData/CloudKit/StateSerialization.swift b/Sources/SQLiteData/CloudKit/StateSerialization.swift index 9df79e4c..79ccb114 100644 --- a/Sources/SQLiteData/CloudKit/StateSerialization.swift +++ b/Sources/SQLiteData/CloudKit/StateSerialization.swift @@ -1,13 +1,13 @@ #if canImport(CloudKit) -import CloudKit -import StructuredQueriesCore + import CloudKit + import StructuredQueriesCore -// @Table("\(String.sqliteDataCloudKitSchemaName)_stateSerialization") -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package struct StateSerialization { - // @Column(as: CKDatabase.Scope.RawValueRepresentation.self, primaryKey: true) - package var scope: CKDatabase.Scope - // @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) - package var data: CKSyncEngine.State.Serialization -} + // @Table("\(String.sqliteDataCloudKitSchemaName)_stateSerialization") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package struct StateSerialization { + // @Column(as: CKDatabase.Scope.RawValueRepresentation.self, primaryKey: true) + package var scope: CKDatabase.Scope + // @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) + package var data: CKSyncEngine.State.Serialization + } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index aed601df..56dc335c 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1870,20 +1870,20 @@ for table in tables { let columnsWithUniqueConstraints = - try SQLQueryExpression( - """ - SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) - WHERE "unique" = 1 AND "origin" <> 'pk' - """, - as: String.self - ) - .fetchAll(db) + try SQLQueryExpression( + """ + SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) + WHERE "unique" = 1 AND "origin" <> 'pk' + """, + as: String.self + ) + .fetchAll(db) if !columnsWithUniqueConstraints.isEmpty { 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/SyncEngineProtocol+Live.swift b/Sources/SQLiteData/CloudKit/SyncEngineProtocol+Live.swift index 3aa0cdb3..d462e198 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngineProtocol+Live.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngineProtocol+Live.swift @@ -1,18 +1,18 @@ #if canImport(CloudKit) -import CloudKit + import CloudKit -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngine: SyncEngineProtocol { - package func recordZoneChangeBatch( - pendingChanges: [PendingRecordZoneChange], - recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? - ) async -> RecordZoneChangeBatch? { - await CKSyncEngine - .RecordZoneChangeBatch(pendingChanges: pendingChanges, recordProvider: recordProvider) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension CKSyncEngine: SyncEngineProtocol { + package func recordZoneChangeBatch( + pendingChanges: [PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> RecordZoneChangeBatch? { + await CKSyncEngine + .RecordZoneChangeBatch(pendingChanges: pendingChanges, recordProvider: recordProvider) + } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngine.State: CKSyncEngineStateProtocol { -} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension CKSyncEngine.State: CKSyncEngineStateProtocol { + } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/SyncEngineProtocol.swift index 1ddc0b7a..99dc5569 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngineProtocol.swift @@ -1,39 +1,39 @@ #if canImport(CloudKit) -import CloudKit + import CloudKit -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package protocol SyncEngineDelegate: AnyObject, Sendable { - func handleEvent(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) async - func nextRecordZoneChangeBatch( - reason: CKSyncEngine.SyncReason, - options: CKSyncEngine.SendChangesOptions, - syncEngine: any SyncEngineProtocol - ) async -> CKSyncEngine.RecordZoneChangeBatch? -} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package protocol SyncEngineDelegate: AnyObject, Sendable { + func handleEvent(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) async + func nextRecordZoneChangeBatch( + reason: CKSyncEngine.SyncReason, + options: CKSyncEngine.SendChangesOptions, + syncEngine: any SyncEngineProtocol + ) async -> CKSyncEngine.RecordZoneChangeBatch? + } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package protocol SyncEngineProtocol: AnyObject, Sendable { - associatedtype State: CKSyncEngineStateProtocol - associatedtype Database: CloudDatabase + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package protocol SyncEngineProtocol: AnyObject, Sendable { + associatedtype State: CKSyncEngineStateProtocol + associatedtype Database: CloudDatabase - var database: Database { get } - var state: State { get } + var database: Database { get } + var state: State { get } - func cancelOperations() async - func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws - func recordZoneChangeBatch( - pendingChanges: [CKSyncEngine.PendingRecordZoneChange], - recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? - ) async -> CKSyncEngine.RecordZoneChangeBatch? -} + func cancelOperations() async + func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws + func recordZoneChangeBatch( + pendingChanges: [CKSyncEngine.PendingRecordZoneChange], + recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? + ) async -> CKSyncEngine.RecordZoneChangeBatch? + } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package protocol CKSyncEngineStateProtocol: Sendable { - var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } - var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { get } - func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) - func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) - func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) - func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) -} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package protocol CKSyncEngineStateProtocol: Sendable { + var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } + var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { get } + func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) + func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) + func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) + func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) + } #endif diff --git a/Sources/SQLiteData/CloudKit/Triggers.swift b/Sources/SQLiteData/CloudKit/Triggers.swift index 04a907a9..8dfaaf4f 100644 --- a/Sources/SQLiteData/CloudKit/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Triggers.swift @@ -14,16 +14,18 @@ ] } - fileprivate static func afterPrimaryKeyChange(parentForeignKey: ForeignKey?) -> TemporaryTrigger { + fileprivate static func afterPrimaryKeyChange(parentForeignKey: ForeignKey?) + -> TemporaryTrigger + { createTemporaryTrigger( "\(String.sqliteDataCloudKitSchemaName)_after_primary_key_change_on_\(tableName)", ifNotExists: true, after: .update(of: \.primaryKey) { old, new in checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) - SyncMetadata + SyncMetadata .where { $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) - && $0.recordType.eq(tableName) + && $0.recordType.eq(tableName) } .update { $0._isDeleted = true } } when: { old, new in diff --git a/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift b/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift index 4841380f..0ec9e31e 100644 --- a/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift +++ b/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift @@ -22,7 +22,9 @@ && $0.ownerName.eq(recordID.zoneID.ownerName) } } - package static func findAll(_ recordIDs: some Collection) -> Where { + package static func findAll(_ recordIDs: some Collection) -> Where< + UnsyncedRecordID + > { let condition: QueryFragment = recordIDs.map { "(\(bind: $0.recordName), \(bind: $0.zoneID.zoneName), \(bind: $0.zoneID.ownerName))" } diff --git a/Sources/SQLiteData/Internal/DataManager.swift b/Sources/SQLiteData/Internal/DataManager.swift index 68dd2640..68b43ffe 100644 --- a/Sources/SQLiteData/Internal/DataManager.swift +++ b/Sources/SQLiteData/Internal/DataManager.swift @@ -54,7 +54,7 @@ private enum DataManagerKey: DependencyKey { } extension DependencyValues { - package var dataManager: DataManager { + package var dataManager: DataManager { get { self[DataManagerKey.self] } set { self[DataManagerKey.self] = newValue } } diff --git a/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift index 56562e39..842d5c72 100644 --- a/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift +++ b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift @@ -66,8 +66,8 @@ } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension AnimatedScheduler: Hashable {} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension AnimatedScheduler: Hashable {} extension ValueObservationScheduler where Self == AnimatedScheduler { package static func animation(_ animation: Animation) -> Self { diff --git a/Sources/SQLiteDataTestSupport/AssertQuery.swift b/Sources/SQLiteDataTestSupport/AssertQuery.swift index d381e981..3009db47 100644 --- a/Sources/SQLiteDataTestSupport/AssertQuery.swift +++ b/Sources/SQLiteDataTestSupport/AssertQuery.swift @@ -47,7 +47,9 @@ import StructuredQueriesTestSupport /// - column: The source `#column` associated with the assertion. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @_disfavoredOverload -public func assertQuery>( +public func assertQuery< + each V: QueryRepresentable, S: StructuredQueriesCore.Statement<(repeat each V)> +>( includeSQL: Bool = false, _ query: S, database: (any DatabaseWriter)? = nil, From 48d45a215071612c8efa716f8400f9d872024e12 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:06:21 -0700 Subject: [PATCH 517/581] wip --- Examples/CloudKitDemo/Schema.swift | 11 +- .../CloudKitDemoTests/CloudKitDemoTests.swift | 6 +- Examples/CloudKitPlayground/ContentView.swift | 18 +-- Examples/CloudKitPlayground/ModelBView.swift | 21 +-- Examples/CloudKitPlayground/ModelCView.swift | 18 ++- Examples/CloudKitPlayground/Schema.swift | 18 ++- Examples/Reminders/RemindersApp.swift | 3 +- Examples/Reminders/RemindersListForm.swift | 6 +- Examples/Reminders/Schema.swift | 117 ++++++++------ Examples/Reminders/TagsForm.swift | 2 +- Examples/RemindersTests/Internal.swift | 4 +- Examples/SyncUps/Schema.swift | 64 ++++---- Examples/SyncUps/SyncUpsList.swift | 48 +++--- Makefile | 6 +- Package.swift | 4 +- Package@swift-6.0.swift | 2 +- README.md | 26 +-- .../Documentation.docc/Articles/CloudKit.md | 138 ++++++++-------- .../Articles/CloudKitSharing.md | 66 ++++---- .../Articles/ComparisonWithSwiftData.md | 150 +++++++++--------- .../Articles/DynamicQueries.md | 4 +- .../Documentation.docc/Articles/Fetching.md | 26 +-- .../Articles/MigrationGuides.md | 2 +- .../MigrationGuides/MigratingTo0.2.md | 12 +- .../Documentation.docc/Articles/Observing.md | 24 +-- .../Articles/PreparingDatabase.md | 24 +-- .../Documentation.docc/SQLiteData.md | 12 +- Tests/SQLiteDataTests/AssertQueryTests.swift | 2 +- .../CloudKitTests/CloudKitTests.swift | 14 +- .../ForeignKeyConstraintTests.swift | 148 ++++++++--------- .../CloudKitTests/MetadataTests.swift | 4 +- .../MockCloudDatabaseTests.swift | 21 ++- .../CloudKitTests/RecordTypeTests.swift | 7 +- .../CloudKitTests/SchemaChangeTests.swift | 6 +- .../SharingPermissionsTests.swift | 4 +- .../CloudKitTests/SharingTests.swift | 23 +-- .../CloudKitTests/UserlandTests.swift | 2 +- Tests/SQLiteDataTests/FetchAllTests.swift | 2 +- Tests/SQLiteDataTests/FetchOneTests.swift | 2 +- Tests/SQLiteDataTests/FetchTests.swift | 4 +- Tests/SQLiteDataTests/IntegrationTests.swift | 2 +- .../Internal/CloudKit+CustomDump.swift | 72 +++++---- .../Internal/CloudKitTestHelpers.swift | 2 +- Tests/SQLiteDataTests/Internal/Schema.swift | 42 +++-- Tests/SQLiteDataTests/SharingGRDBTests.swift | 2 +- 45 files changed, 628 insertions(+), 563 deletions(-) diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index c495166d..ca892d0d 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -13,7 +13,8 @@ func appDatabase() throws -> any DatabaseWriter { let database: any DatabaseWriter var configuration = Configuration() configuration.prepareDatabase { db in - try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") + try db.attachMetadatabase( + containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo") #if DEBUG db.trace(options: .profile) { if context == .live { @@ -42,15 +43,17 @@ func appDatabase() throws -> any DatabaseWriter { var migrator = DatabaseMigrator() #if DEBUG - migrator.eraseDatabaseOnSchemaChange = true + migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create tables") { db in - try #sql(""" + try #sql( + """ CREATE TABLE "counters" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "count" INT NOT NULL DEFAULT 0 ) - """) + """ + ) .execute(db) } try migrator.migrate(database) diff --git a/Examples/CloudKitDemoTests/CloudKitDemoTests.swift b/Examples/CloudKitDemoTests/CloudKitDemoTests.swift index 291bacb3..76468d0e 100644 --- a/Examples/CloudKitDemoTests/CloudKitDemoTests.swift +++ b/Examples/CloudKitDemoTests/CloudKitDemoTests.swift @@ -9,8 +9,8 @@ import Testing struct CloudKitDemoTests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } } diff --git a/Examples/CloudKitPlayground/ContentView.swift b/Examples/CloudKitPlayground/ContentView.swift index 77266cff..4503945e 100644 --- a/Examples/CloudKitPlayground/ContentView.swift +++ b/Examples/CloudKitPlayground/ContentView.swift @@ -8,17 +8,17 @@ import SwiftUI struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") } + .padding() + } } #Preview { - ContentView() + ContentView() } diff --git a/Examples/CloudKitPlayground/ModelBView.swift b/Examples/CloudKitPlayground/ModelBView.swift index 2a426f47..0587fbd8 100644 --- a/Examples/CloudKitPlayground/ModelBView.swift +++ b/Examples/CloudKitPlayground/ModelBView.swift @@ -15,15 +15,17 @@ struct ModelBView: View { List { ForEach(models) { model in HStack { - Toggle("On? \(model.isOn ? "YES" : "NO")", isOn: Binding { - model.isOn - } set: { newValue in - withErrorReporting { - try database.write { db in - try ModelB.find(model.id).update { $0.isOn = newValue }.execute(db) + Toggle( + "On? \(model.isOn ? "YES" : "NO")", + isOn: Binding { + model.isOn + } set: { newValue in + withErrorReporting { + try database.write { db in + try ModelB.find(model.id).update { $0.isOn = newValue }.execute(db) + } } - } - }) + }) Spacer() NavigationLink("Go") { @@ -53,7 +55,8 @@ struct ModelBView: View { Button("Special") { withErrorReporting { try database.write { db in - let modelB = try ModelB.insert { ModelB.Draft(modelAID: modelA.id) }.returning(\.self).fetchOne(db) + let modelB = try ModelB.insert { ModelB.Draft(modelAID: modelA.id) }.returning(\.self) + .fetchOne(db) guard let modelB else { return } diff --git a/Examples/CloudKitPlayground/ModelCView.swift b/Examples/CloudKitPlayground/ModelCView.swift index 6687f9d5..51b9546c 100644 --- a/Examples/CloudKitPlayground/ModelCView.swift +++ b/Examples/CloudKitPlayground/ModelCView.swift @@ -15,15 +15,17 @@ struct ModelCView: View { List { ForEach(models) { model in HStack { - TextField("Title", text: Binding { - model.title - } set: { newValue in - withErrorReporting { - try database.write { db in - try ModelC.find(model.id).update { $0.title = newValue }.execute(db) + TextField( + "Title", + text: Binding { + model.title + } set: { newValue in + withErrorReporting { + try database.write { db in + try ModelC.find(model.id).update { $0.title = newValue }.execute(db) + } } - } - }) + }) } .buttonStyle(.plain) } diff --git a/Examples/CloudKitPlayground/Schema.swift b/Examples/CloudKitPlayground/Schema.swift index 1b6f323f..57489dfc 100644 --- a/Examples/CloudKitPlayground/Schema.swift +++ b/Examples/CloudKitPlayground/Schema.swift @@ -50,28 +50,34 @@ func appDatabase() throws -> any DatabaseWriter { } var migrator = DatabaseMigrator() migrator.registerMigration("Create tables") { db in - try #sql(""" + try #sql( + """ CREATE TABLE "modelAs" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "count" INTEGER NOT NULL ) - """) + """ + ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TABLE "modelBs" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "isOn" INTEGER NOT NULL, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) - """) + """ + ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TABLE "modelCs" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL, "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) - """) + """ + ) .execute(db) } try migrator.migrate(database) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 1f05cf24..a4222ded 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -2,11 +2,10 @@ import CloudKit import Combine import Dependencies import SQLiteData +import SwiftData import SwiftUI import UIKit -import SwiftData - @main struct RemindersApp: App { @UIApplicationDelegateAdaptor var delegate: AppDelegate diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 31dde0e3..98d4a022 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -34,7 +34,8 @@ struct RemindersListForm: View { ZStack(alignment: .topTrailing) { ZStack { if let coverImageData, - let uiImage = UIImage(data: coverImageData) { + let uiImage = UIImage(data: coverImageData) + { Image(uiImage: uiImage) .resizable() .scaledToFill() @@ -77,7 +78,8 @@ struct RemindersListForm: View { Task { [remindersList, coverImageData] in await withErrorReporting { try await database.write { db in - let remindersListID = try RemindersList + let remindersListID = + try RemindersList .upsert { remindersList } .returning(\.id) .fetchOne(db) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 6f576520..73bcbbbf 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -224,61 +224,73 @@ func appDatabase() throws -> any DatabaseWriter { try db.seedSampleData() } - try RemindersList.createTemporaryTrigger(after: .insert { new in - RemindersList - .find(new.id) - .update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1} } - }) + try RemindersList.createTemporaryTrigger( + after: .insert { new in + RemindersList + .find(new.id) + .update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1 } } + } + ) .execute(db) - try Reminder.createTemporaryTrigger(after: .insert { new in - Reminder - .find(new.id) - .update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1} } - }) + try Reminder.createTemporaryTrigger( + after: .insert { new in + Reminder + .find(new.id) + .update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1 } } + } + ) .execute(db) - try RemindersList.createTemporaryTrigger(after: .delete { _ in - RemindersList.insert { - RemindersList.Draft( - color: RemindersList.defaultColor, - title: RemindersList.defaultTitle - ) + try RemindersList.createTemporaryTrigger( + after: .delete { _ in + RemindersList.insert { + RemindersList.Draft( + color: RemindersList.defaultColor, + title: RemindersList.defaultTitle + ) + } + } when: { _ in + !RemindersList.exists() } - } when: { _ in - !RemindersList.exists() - }) + ) .execute(db) - try Reminder.createTemporaryTrigger(after: .insert { new in - ReminderText.insert { - ReminderText.Columns( - rowid: new.rowid, - title: new.title, - notes: new.notes.replace("\n", " "), - tags: "" - ) + try Reminder.createTemporaryTrigger( + after: .insert { new in + ReminderText.insert { + ReminderText.Columns( + rowid: new.rowid, + title: new.title, + notes: new.notes.replace("\n", " "), + tags: "" + ) + } } - }) + ) .execute(db) - try Reminder.createTemporaryTrigger(after: .update { - ($0.title, $0.notes) - } forEachRow: { _, new in - ReminderText - .where { $0.rowid.eq(new.rowid) } - .update { - $0.title = new.title - $0.notes = new.notes.replace("\n", " ") - } - }) + try Reminder.createTemporaryTrigger( + after: .update { + ($0.title, $0.notes) + } forEachRow: { _, new in + ReminderText + .where { $0.rowid.eq(new.rowid) } + .update { + $0.title = new.title + $0.notes = new.notes.replace("\n", " ") + } + } + ) .execute(db) - try Reminder.createTemporaryTrigger(after: .delete { old in - ReminderText - .where { $0.rowid.eq(old.rowid) } - .delete() - }) + try Reminder.createTemporaryTrigger( + after: .delete { old in + ReminderText + .where { $0.rowid.eq(old.rowid) } + .delete() + } + ) .execute(db) func updateReminderTextTags( @@ -287,7 +299,8 @@ func appDatabase() throws -> any DatabaseWriter { ReminderText .where { $0.rowid.eq(Reminder.find(reminderID).select(\.rowid)) } .update { - $0.tags = ReminderTag + $0.tags = + ReminderTag .order(by: \.tagID) .where { $0.reminderID.eq(reminderID) } .join(Tag.all) { $0.tagID.eq($1.primaryKey) } @@ -295,14 +308,18 @@ func appDatabase() throws -> any DatabaseWriter { } } - try ReminderTag.createTemporaryTrigger(after: .insert { new in - updateReminderTextTags(for: new.reminderID) - }) + try ReminderTag.createTemporaryTrigger( + after: .insert { new in + updateReminderTextTags(for: new.reminderID) + } + ) .execute(db) - try ReminderTag.createTemporaryTrigger(after: .delete { old in - updateReminderTextTags(for: old.reminderID) - }) + try ReminderTag.createTemporaryTrigger( + after: .delete { old in + updateReminderTextTags(for: old.reminderID) + } + ) .execute(db) } diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 7beb0b96..1a60166e 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -103,7 +103,7 @@ struct TagsView: View { .execute(db) } else { try Tag.insert(or: .ignore) { tag } - .execute(db) + .execute(db) } } selectedTags.append(tag) diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index 5ee171dc..a58ed92b 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -8,7 +8,7 @@ import Testing @Suite( .dependency(\.continuousClock, ImmediateClock()), - .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)), + .dependency(\.date.now, Date(timeIntervalSince1970: 1_234_567_890)), .dependency(\.uuid, .incrementing), .dependencies { $0.defaultDatabase = try Reminders.appDatabase() @@ -27,7 +27,7 @@ extension RemindersList: @retroactive CustomDumpReflectable { "id": id, "color": Color.HexRepresentation(queryOutput: color).hexValue ?? 0, "position": position, - "title": title + "title": title, ], displayStyle: .struct ) diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index 04d95d6d..2a186271 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -95,9 +95,9 @@ func appDatabase() throws -> any DatabaseWriter { database = try DatabaseQueue(configuration: configuration) } else { let path = - context == .live - ? URL.documentsDirectory.appending(component: "db.sqlite").path() - : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + context == .live + ? URL.documentsDirectory.appending(component: "db.sqlite").path() + : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() logger.info("open \(path)") database = try DatabasePool(path: path, configuration: configuration) } @@ -158,36 +158,36 @@ func appDatabase() throws -> any DatabaseWriter { private let logger = Logger(subsystem: "SyncUps", category: "Database") #if DEBUG -extension Database { - func seedSampleData() throws { - try seed { - SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") - SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") - SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") - - for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { - Attendee.Draft(name: name, syncUpID: UUID(1)) - } - for name in ["Blob", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(2)) - } - for name in ["Blob Sr", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(3)) - } + extension Database { + func seedSampleData() throws { + try seed { + SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") + SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") + + for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { + Attendee.Draft(name: name, syncUpID: UUID(1)) + } + for name in ["Blob", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(2)) + } + for name in ["Blob Sr", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(3)) + } - Meeting.Draft( - date: Date().addingTimeInterval(-60 * 60 * 24 * 7), - syncUpID: UUID(1), - transcript: """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ - irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ - deserunt mollit anim id est laborum. - """ - ) + Meeting.Draft( + date: Date().addingTimeInterval(-60 * 60 * 24 * 7), + syncUpID: UUID(1), + transcript: """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ + deserunt mollit anim id est laborum. + """ + ) + } } } -} #endif diff --git a/Examples/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUpsList.swift index 7efa1295..8d065080 100644 --- a/Examples/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUpsList.swift @@ -32,13 +32,13 @@ final class SyncUpsListModel { } #if DEBUG - func seedDatabase() { - withErrorReporting { - try database.write { db in - try db.seedSampleData() + func seedDatabase() { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } } } - } #endif @Selection @@ -69,30 +69,30 @@ struct SyncUpsList: View { Image(systemName: "plus") } } -#if DEBUG - ToolbarItem(placement: .automatic) { - Menu { - Button { - model.seedDatabase() + #if DEBUG + ToolbarItem(placement: .automatic) { + Menu { + Button { + model.seedDatabase() + } label: { + Text("Seed data") + Image(systemName: "leaf") + } } label: { - Text("Seed data") - Image(systemName: "leaf") + Image(systemName: "ellipsis.circle") } - } label: { - Image(systemName: "ellipsis.circle") - } - .popoverTip(seedDatabaseTip) - .task { - await withErrorReporting { - try Tips.configure() - try await model.$syncUps.load() - if model.syncUps.isEmpty { - seedDatabaseTip = SeedDatabaseTip() + .popoverTip(seedDatabaseTip) + .task { + await withErrorReporting { + try Tips.configure() + try await model.$syncUps.load() + if model.syncUps.isEmpty { + seedDatabaseTip = SeedDatabaseTip() + } } } } - } -#endif + #endif } .navigationTitle("Daily Sync-ups") .sheet(item: $model.addSyncUp) { syncUpFormModel in diff --git a/Makefile b/Makefile index 5d1b0122..d23c3fc0 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,11 @@ xcodebuild: warm-simulator xcodebuild-raw: warm-simulator $(XCODEBUILD_COMMAND) -.PHONY: warm-simulator xcodebuild xcodebuild-raw +format: + swift format . --recursive --in-place + find README.md Sources -name '*.md' -exec sed -i '' -e 's/ *$$//g' {} \; + +.PHONY: format warm-simulator xcodebuild xcodebuild-raw define udid_for $(shell xcrun simctl list --json devices available '$(1)' | jq -r '[.devices|to_entries|sort_by(.key)|reverse|.[].value|select(length > 0)|.[0]][0].udid') diff --git a/Package.swift b/Package.swift index c721f053..ce3e8b34 100644 --- a/Package.swift +++ b/Package.swift @@ -38,7 +38,7 @@ let package = Package( url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.16.0", traits: [ - .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SQLiteDataTagged"])), + .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SQLiteDataTagged"])) ] ), .package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.10.0"), @@ -86,7 +86,7 @@ let package = Package( ) let swiftSettings: [SwiftSetting] = [ - .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("MemberImportVisibility") // .unsafeFlags([ // "-Xfrontend", // "-warn-long-function-bodies=50", diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index ccd04032..499b0acb 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -67,7 +67,7 @@ let package = Package( ) let swiftSettings: [SwiftSetting] = [ - .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("MemberImportVisibility") // .unsafeFlags([ // "-Xfrontend", // "-warn-long-function-bodies=50", diff --git a/README.md b/README.md index c0bf6f10..ed1db5e5 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ similar to SwiftData's `@Model` and `@Query`:
- + ```swift @FetchAll var items: [Item] @@ -116,7 +116,7 @@ struct MyApp: App { init() { prepareDependencies { let db = try! DatabaseQueue( - // Create/migrate a database + // Create/migrate a database // connection ) $0.defaultDatabase = db @@ -132,11 +132,11 @@ struct MyApp: App { ```swift @main struct MyApp: App { - let container = { + let container = { // Create/configure a container try! ModelContainer(/* ... */) }() - + var body: some Scene { WindowGroup { ContentView() @@ -154,7 +154,7 @@ struct MyApp: App { > For more information on preparing a SQLite database, see > [Preparing a SQLite database][preparing-db-article]. -This `defaultDatabase` connection is used implicitly by SQLiteData's strategies, like +This `defaultDatabase` connection is used implicitly by SQLiteData's strategies, like [`@FetchAll`][fetchall-docs] and [`@FetchOne`][fetchone-docs], which are similar to SwiftData's `@Query` macro, but more powerful: @@ -219,9 +219,9 @@ a model context, via a property wrapper: ```swift -@Dependency(\.defaultDatabase) +@Dependency(\.defaultDatabase) var database - + let newItem = Item(/* ... */) try database.write { db in try Item.insert { newItem } @@ -233,9 +233,9 @@ try database.write { db in ```swift -@Environment(\.modelContext) +@Environment(\.modelContext) var modelContext - + let newItem = Item(/* ... */) modelContext.insert(newItem) try modelContext.save() @@ -251,7 +251,7 @@ try modelContext.save() > [Comparison with SwiftData][comparison-swiftdata-article]. Further, if you want to synchronize the local database to CloudKit so that it is available on -all your user's devices, simply configure a `SyncEngine` in the entry point of the app: +all your user's devices, simply configure a `SyncEngine` in the entry point of the app: ```swift @main @@ -315,8 +315,8 @@ Orders.fetchAll setup rampup duration ## SQLite knowledge required -SQLite is one of the -[most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of +SQLite is one of the +[most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of software in the history of software. Knowledge of SQLite is a great skill for any app developer to have, and this library does not want to conceal it from you. So, we feel that to best wield this library you should be familiar with the basics of SQLite, including schema design and normalization, @@ -343,7 +343,7 @@ SQLiteData. Check out [this](./Examples) directory to see them all, including: * [SyncUps](./Examples/SyncUps): We also rebuilt Apple's [Scrumdinger][] demo application using modern, best practices for SwiftUI development, including using this library to query and persist state using SQLite. - + * [Reminders](./Examples/Reminders): A rebuild of Apple's [Reminders][reminders-app-store] app that uses a SQLite database to model the reminders, lists and tags. It features many advanced queries, such as searching, and stats aggregation. diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 7d5b9331..38983b4e 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -39,14 +39,14 @@ to make sure you understand how to best prepare your app for cloud synchronizati ## Setting up your project -The steps to set up your SQLiteData project for CloudKit synchronization are the +The steps to set up your SQLiteData project for CloudKit synchronization are the [same for setting up][setup-cloudkit-apple] any other kind of project for CloudKit: * Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project. * Follow the [Configuring background execution modes] guide for adding the Background Modes capability to your project. - * If you want to enable sharing of records with other iCloud users, be sure to add a - `CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented + * If you want to enable sharing of records with other iCloud users, be sure to add a + `CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented in [Apple's documentation for sharing]. * Once you are ready to deploy your app be sure to read Apple's documentation on [Deploying an iCloud Container’s Schema]. @@ -68,14 +68,14 @@ for changes in your database to play them back to CloudKit, and listen for chang play them back to SQLite. Before constructing a ``SyncEngine`` you must have already created and migrated your app's local -SQLite database as detailed in . Immediately after that is done in the -`prepareDependencies` of the entry point of your app you will override the +SQLite database as detailed in . Immediately after that is done in the +`prepareDependencies` of the entry point of your app you will override the ``Dependencies/DependencyValues/defaultSyncEngine`` dependency with a sync engine that specifies the CloudKit container to use, the database to synchronize, as well as the tables you want to synchronize: ```swift -@main +@main struct MyApp: App { init() { try! prepareDependencies { @@ -86,12 +86,12 @@ struct MyApp: App { ) } } - + // ... } ``` -The `SyncEngine` +The `SyncEngine` [initializer]() has more options you may be interested in configuring. @@ -126,7 +126,7 @@ as below. ## Designing your schema with synchronization in mind Distributing your app's schema across many devices is a big decision to make for your app, and -care must be taken. It is not true that you can simply take any existing schema, add a +care must be taken. It is not true that you can simply take any existing schema, add a ``SyncEngine`` to it, and have it magically synchronize data across all devices and across all versions of your app. There are a number of principles to keep in mind while designing and evolving your schema to make sure every device can synchronize changes to every other device, no matter the @@ -137,14 +137,14 @@ version. > TL;DR: Primary keys should be globally unique identifiers, such as UUID. We further recommend > specifying a `NOT NULL` constraint with a `ON CONFLICT REPLACE` action. -Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a +Primary keys are an important concept in SQL schema design, and SQLite makes it easy to add a primary key by using an `AUTOINCREMENT` integer. This makes it so that newly inserted rows get a unique ID by simply adding 1 to the largest ID in the table. However, that does not play nicely -with distributed schemas. That would make it possible for two devices to create a record with +with distributed schemas. That would make it possible for two devices to create a record with `id: 1`, and when those records synchronize there would be an irreconcilable conflict. -For this reason, primary keys in SQLite tables should be globally unique, such as a UUID. The -easiest way to do this is to store your table's ID in a `TEXT` column, adding a +For this reason, primary keys in SQLite tables should be globally unique, such as a UUID. The +easiest way to do this is to store your table's ID in a `TEXT` column, adding a default with a freshly generated UUID, and further adding a `ON CONFLICT REPLACE` constraint: ```sql @@ -164,14 +164,14 @@ the primary key from the default value specified. This kind of pattern is common try database.write { db in try Reminder.upsert { // Do not provide 'id', let database initialize it for you. - Reminder.Draft(title: "Get milk") + Reminder.Draft(title: "Get milk") } .execute(db) } ``` If you would like to use a unique identifier other than the `UUID` provided by Foundation, you can -conform your identifier type to ``IdentifierStringConvertible``. We still recommend using +conform your identifier type to ``IdentifierStringConvertible``. We still recommend using `NOT NULL ON CONFLICT REPLACE` on your column, as well as a default, but the default will need to be provided outside of SQLite. You can do this by registering a function in SQLite and calling out to it for the default value of your column: @@ -184,15 +184,15 @@ CREATE TABLE "reminders" ( ``` > Tip: If you want the database to generate random UUID's in a deterministic fashion for tests -> you can register a custom database function to be used. +> you can register a custom database function to be used. #### Primary keys on every table -> TL;DR: Each synchronized table must have a single, non-compound primary key to aid in +> TL;DR: Each synchronized table must have a single, non-compound primary key to aid in > synchronization, even if it is not used by your app. _Every_ table being synchronized must have a single primary key and cannot have compound primary -keys. This includes join tables that typically only have two foreign keys pointing to the two +keys. This includes join tables that typically only have two foreign keys pointing to the two tables they are joining. For example, a `ReminderTag` table that joins reminders to tags should be designed like so: @@ -204,7 +204,7 @@ CREATE TABLE "reminderTags" ( ) ``` -Note that the `id` column might not be needed for your application's logic, but it is necessary to +Note that the `id` column might not be needed for your application's logic, but it is necessary to facilitate synchronizing to CloudKit. #### Unique constraints @@ -213,14 +213,14 @@ facilitate synchronizing to CloudKit. > for distributed creation of records. Tables with unique constraints on their columns, other than on the primary key, cannot be -synchronized. As an example, suppose you have a `Tag` table with a unique constraint on the +synchronized. As an example, suppose you have a `Tag` table with a unique constraint on the `title` column. It is not clear how the application should handle if two different devices create a tag with the title "Family" at the same time. When the two devices synchronize their data -they will have a conflict on the uniqueness constraint, but it would not be correct to +they will have a conflict on the uniqueness constraint, but it would not be correct to discard one of the tags. For this reason uniqueness constraints are not allowed in schemas, and this will be validated -when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be +when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be thrown. #### Foreign key relationships @@ -228,11 +228,11 @@ thrown. > TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to > cascade deletions. -SQLiteData can synchronize many-to-one and many-to-many relationships to CloudKit, +SQLiteData can synchronize many-to-one and many-to-many relationships to CloudKit, and you can enforce foreign key constraints in your database connection. While it is possible for -the sync engine to receive records in an order that could cause a foreign key constraint failure, +the sync engine to receive records in an order that could cause a foreign key constraint failure, such as receiving a child record before its parent, the sync engine will cache the child record -until the parent record has been synchronized, at which point the child record will also be +until the parent record has been synchronized, at which point the child record will also be synchronized. Currently the only actions supported for `ON DELETE` are `CASCADE`, `SET NULL` and `SET DEFAULT`. @@ -244,14 +244,14 @@ in your schema an error will be thrown when constructing ``SyncEngine``. > TL;DR: Conflicts are handled automatically using a "last edit wins" strategy for each > column of the record. -Conflicts between record edits will inevitably happen, and it's just a fact of dealing with +Conflicts between record edits will inevitably happen, and it's just a fact of dealing with distributed data. The library handles conflicts automatically, but does so with a single strategy that is currently not customizable. When a column is edited on a record, the library keeps track of the timestamp for that particular column. When merging two conflicting records, each column is analyzed, and the column that was most recently edited will win over the older data. We do not employ more advanced merge conflict strategies, such as CRDT synchronization. We may -allow for these kinds of strategies in the future, but for now "field-wise last edit wins" is +allow for these kinds of strategies in the future, but for now "field-wise last edit wins" is the only strategy available and we feel serves the needs of the most number of people. ## Backwards compatible migrations @@ -268,13 +268,13 @@ cause problems if your migration is not designed correctly. Adding new tables to a schema is perfectly safe thing to do in a CloudKit application. If a record from a device is synchronized to a device that does not have that table it will cache the record -for later use. Then, when a device updates to the newest version of the app and detects a new table +for later use. Then, when a device updates to the newest version of the app and detects a new table has been added to the schema, it will populate the table with the cached records it received. #### Adding columns > TL;DR: When adding columns to a table that has already been deployed to users' devices, you will -either need to make the column nullable, or it can be `NOT NULL` but a default value must be +either need to make the column nullable, or it can be `NOT NULL` but a default value must be provided with an `ON CONFLICT REPLACE` clause. As an example, suppose the 1.0 of your app shipped a table for a reminders list: @@ -282,7 +282,7 @@ As an example, suppose the 1.0 of your app shipped a table for a reminders list: ```swift @Table struct RemindersList { - let id: UUID + let id: UUID var title = "" } ``` @@ -301,7 +301,7 @@ Next suppose in 1.1 you want to add a column to the `RemindersList` type: ```diff @Table struct RemindersList { - let id: UUID + let id: UUID var title = "" + var position = 0 } @@ -310,7 +310,7 @@ Next suppose in 1.1 you want to add a column to the `RemindersList` type: …with the corresponding SQL migration: ```sql -ALTER TABLE "remindersLists" +ALTER TABLE "remindersLists" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 ``` @@ -319,9 +319,9 @@ app creates a record, it will not have the `position` field. And when that synch running the 1.1 of the app, the ``SyncEngine`` will attempt to run a query that is essentially this: ```sql -INSERT INTO "remindersLists" +INSERT INTO "remindersLists" ("id", "title", "position") -VALUES +VALUES (NULL, 'Personal', NULL) ``` @@ -332,32 +332,32 @@ The fix is to allow for inserting `NULL` values into `NOT NULL` columns by using column. This can be done like so: ```sql -ALTER TABLE "remindersLists" +ALTER TABLE "remindersLists" ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 ``` -> Important: The `ON CONFLICT REPLACE` clause must come directly after `NOT NULL` because it +> Important: The `ON CONFLICT REPLACE` clause must come directly after `NOT NULL` because it > modifies that constraint. -Now when this query is executed: +Now when this query is executed: ```sql -INSERT INTO "remindersLists" +INSERT INTO "remindersLists" ("id", "title", "position") -VALUES +VALUES (NULL, 'Personal', NULL) ``` …it will use 0 for the `position` column. Sometimes it is not possible to specify a default for a newly added column. Suppose in version 1.2 -of your app you add groups for reminders lists. This can be expressed as a new field on the +of your app you add groups for reminders lists. This can be expressed as a new field on the `RemindersList` type: ```diff @Table struct RemindersList { - let id: UUID + let id: UUID var title = "" var position = 0 + var remindersListGroupID: RemindersListGroup.ID @@ -368,19 +368,19 @@ However, there is no sensible default that can be used for this schema. But, if table like so: ```sql -ALTER TABLE "remindersLists" +ALTER TABLE "remindersLists" ADD COLUMN "remindersListGroupID" TEXT NOT NULL REFERENCES "remindersListGroups"("id") ``` -…then this will be problematic when older devices create reminders lists with no +…then this will be problematic when older devices create reminders lists with no `remindersListGroupID`. In this situation you have no choice but to make the field optional in the type: ```diff @Table struct RemindersList { - let id: UUID + let id: UUID var title = "" var position = 0 - var remindersListGroupID: RemindersListGroup.ID @@ -391,7 +391,7 @@ the type: And your migration will need to add a nullable column to the table: ```diff - ALTER TABLE "remindersLists" + ALTER TABLE "remindersLists" -ADD COLUMN "remindersListGroupID" TEXT NOT NULL +ADD COLUMN "remindersListGroupID" TEXT REFERENCES "remindersListGroups"("id") @@ -399,7 +399,7 @@ And your migration will need to add a nullable column to the table: It may be disappointing to have to weaken your domain modeling to accommodate synchronization, but that is the unfortunate reality of a distributed schema. In order to allow multiple versions of your -schema to be run on devices so that each device can create new records and edit existing records +schema to be run on devices so that each device can create new records and edit existing records that all devices can see, you will need to make some compromises. #### Disallowed migrations @@ -413,7 +413,7 @@ devices. They are: ## Sharing records with other iCloud users -SQLiteData provides the tools necessary to share a record with another iCloud user so that +SQLiteData provides the tools necessary to share a record with another iCloud user so that multiple users can collaborate on a single record. Sharing a record with another user brings extra complications to an app that go beyond the existing complications of sharing a schema across many devices. Please read the documentation carefully and thoroughly to understand @@ -432,7 +432,7 @@ This process is completely seamless and you do not have to take any explicit ste assets. However, general database design guidelines still apply. In particular, it is not recommended to -store large binary blobs in a table that is queried often. If done naively you may accidentally +store large binary blobs in a table that is queried often. If done naively you may accidentally large amounts of data into memory when querying your table, and further large binary blobs can slow down SQLite's ability to efficiently access the rows in your tables. @@ -443,13 +443,13 @@ table for the image data associated with a reminders list. Further, the primary image table can be the foreign key pointing to the associated reminders list: ```swift -@Table +@Table struct RemindersList: Identifiable { - let id: UUID + let id: UUID var title = "" } -@Table +@Table struct RemindersListCoverImage { @Column(primaryKey: true) let remindersListID: RemindersList.ID @@ -470,10 +470,10 @@ data for a list when you need it. While the library tries to make CloudKit synchronization as seamless and hidden as possible, there are times you will need to access the underlying CloudKit types for your tables and records. -The ``SyncMetadata``table is the central place where this data is stored, and it is publicly +The ``SyncMetadata``table is the central place where this data is stored, and it is publicly exposed for you to query it in whichever way you want. -> Important: In order to query the `SyncMetadata` table from your database connection you will need +> Important: In order to query the `SyncMetadata` table from your database connection you will need to attach the metadatabase to your database connection. This can be done with the ``GRDB/Database/attachMetadatabase(containerIdentifier:)`` method defined on `Database`. See for more information on how to do this. @@ -483,7 +483,7 @@ to construct a SQL query for fetching the meta data associated with one of your For example, if you want to retrieve the `CKRecord` that is associated with a particular row in one of your tables, say a reminder, then you can use ``SyncMetadata/lastKnownServerRecord`` to -retrieve the `CKRecord` and then invoke a CloudKit database function to retrieve all of the details: +retrieve the `CKRecord` and then invoke a CloudKit database function to retrieve all of the details: ```swift let lastKnownServerRecord = try database.read { db in @@ -493,7 +493,7 @@ let lastKnownServerRecord = try database.read { db in .fetchOne(db) ?? nil } -guard let lastKnownServerRecord +guard let lastKnownServerRecord else { return } let ckRecord = try await container.privateCloudDatabase @@ -505,7 +505,7 @@ let ckRecord = try await container.privateCloudDatabase > a shared record, which can be determined from [SyncMetadata.share](), > then you must use `sharedCloudDatabase` to fetch the newest record. -You are free to invoke any CloudKit functions you want with the `CKRecord` retrieved from +You are free to invoke any CloudKit functions you want with the `CKRecord` retrieved from ``SyncMetadata``. Any changes made directly with CloudKit will be automatically synced to your SQLite database by the ``SyncEngine``. @@ -526,13 +526,13 @@ let ckRecord = try await container.sharedCloudDatabase .record(for: share.recordID) ``` -> Important: In the above snippet we are using the `sharedCloudDatabase` and this is always -appropriate to use when fetching the details of a `CKShare` as they are always stored in the +> Important: In the above snippet we are using the `sharedCloudDatabase` and this is always +appropriate to use when fetching the details of a `CKShare` as they are always stored in the shared database. -It is also possible to join the ``SyncMetadata`` table directly to your tables so that you can +It is also possible to join the ``SyncMetadata`` table directly to your tables so that you can select this additional information on a per-record basis. For example, if you want to select all -reminders lists, along with a boolean that determines if it is shared or not, you can do the +reminders lists, along with a boolean that determines if it is shared or not, you can do the following: ```swift @@ -546,7 +546,7 @@ following: .leftJoin(SyncMetadata.all) { $0.recordName.eq($1.recordName) } .select { Row.Columns( - remindersList: $0, + remindersList: $0, isShared: $1.isShared ?? false ) } @@ -565,7 +565,7 @@ is defined on all primary key tables so that we can join ``SyncMetadata`` to `Re It is possible to run your features in tests and previews even when using the ``SyncEngine``. You will need to prepare it for dependencies exactly as you do in the entry point of your app. This -can lead to some code duplication, and so you may want to extract that work to a mutating +can lead to some code duplication, and so you may want to extract that work to a mutating `bootstrapDatabase` method on `DependencyValues` like so: ```swift @@ -587,14 +587,14 @@ extension DependencyValues { Then in your app entry point you can use it like so: ```swift -@main +@main struct MyApp: App { init() { try! prepareDependencies { try! $0.bootstrapDatabase() } } - + // ... } ``` @@ -602,7 +602,7 @@ struct MyApp: App { In tests you can use it like so: ```swift -@Suite(.dependencies { try! $0.bootstrapDatabase() }) +@Suite(.dependencies { try! $0.bootstrapDatabase() }) struct MySuite { // ... } @@ -645,15 +645,15 @@ If you have triggers installed on your tables, then you may want to customize th to behave differently depending on whether a write is happening to your database from your own code or from the sync engine. For example, if you have a trigger that refreshes an `updatedAt` timestamp on a row when it is edited, it would not be appropriate to do that when the sync engine -updates a row from data received from CloudKit. But, if you have a trigger that updates a local +updates a row from data received from CloudKit. But, if you have a trigger that updates a local [FTS] index, then you would want to perform that work regardless if your app is updating the data or CloudKit is updating the data. [FTS]: https://sqlite.org/fts5.html -To customize this behavior you can use the ``SyncEngine/isSynchronizingChanges()`` SQL expression. -It represents a custom database function that is installed in your database connection, and it will -return true if the write to your database originates from the sync engine. You can use it in a +To customize this behavior you can use the ``SyncEngine/isSynchronizingChanges()`` SQL expression. +It represents a custom database function that is installed in your database connection, and it will +return true if the write to your database originates from the sync engine. You can use it in a trigger like so: ```swift diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md index 5fd490bd..50bc341d 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md @@ -40,7 +40,7 @@ so like this: ```swift struct RemindersListView: View { - let remindersList: RemindersList + let remindersList: RemindersList @State var sharedRecord: SharedRecord? var body: some View { @@ -121,7 +121,7 @@ shared record. There is, however, a lot more to know about sharing. There are im placed on what kind of records you are allowed to share, and what associations of those records are shared. -In a nutshell, only "root" records can be directly shared, _i.e._ records with no foreign keys. +In a nutshell, only "root" records can be directly shared, _i.e._ records with no foreign keys. Further, an association of a root record can only be shared if it has only one foreign key pointing to the root record. And this last rule applies recursively: a leaf association is shared only if it has exactly one foreign key pointing to a record that also satisfies this property. @@ -133,13 +133,13 @@ For more in-depth information, keep reading. > Important: It is only possible to share "root" records, _i.e._ records with no foreign keys. A record can be shared only if it is a "root" record. That means it cannot have any -foreign keys whatsoever. As an example, the following `RemindersList` table is a root record because +foreign keys whatsoever. As an example, the following `RemindersList` table is a root record because it does not have any fields pointing to other tables: ```swift -@Table +@Table struct RemindersList: Identifiable { - let id: UUID + let id: UUID var title = "" } ``` @@ -148,9 +148,9 @@ On the other hand, a `Reminder` table with a foreign key pointing to the `Remind a root record: ```swift -@Table +@Table struct Reminder: Identifiable { - let id: UUID + let id: UUID var title = "" var isCompleted = false var remindersListID: RemindersList.ID @@ -158,7 +158,7 @@ struct Reminder: Identifiable { ``` Such records cannot be shared because it is not appropriate to also share the parent record (_i.e._ -the reminders list). +the reminders list). For example, suppose you have a list named "Personal" with a reminder "Get milk". If you share this reminder with someone, then it becomes difficult to figure out what to do when they make certain @@ -174,7 +174,7 @@ you can share root records, like reminders lists. If you do invoke ``SyncEngine/share(record:configure:)`` with a non-root record, an error will be thrown. > Note: A reminder can still be shared as an association to a shared reminders list, as discussed -> [in the next section](). However, a single +> [in the next section](). However, a single > reminder cannot be shared on its own. For a more complex example, consider the following diagrammatic schema for a reminders app: @@ -209,15 +209,15 @@ One-to-many relationships are the simplest to share with other users. As an exam `RemindersList` table that can have many `Reminder`s associated with it: ```swift -@Table +@Table struct RemindersList: Identifiable { - let id: UUID + let id: UUID var title = "" } -@Table +@Table struct Reminder: Identifiable { - let id: UUID + let id: UUID var title = "" var isCompleted = false var remindersListID: RemindersList.ID @@ -231,9 +231,9 @@ Further, suppose there was a `ChildReminder` table that had a single foreign key `Reminder`: ```swift -@Table +@Table struct ChildReminder: Identifiable { - let id: UUID + let id: UUID var title = "" var isCompleted = false var parentReminderID: Reminders.ID @@ -259,7 +259,7 @@ foreign key pointing to a table that also has a single foreign key pointing to t Many-to-many relationships pose a significant problem to sharing and cannot be supported. If a table has multiple foreign keys, then it will not be shared even if one of those foreign keys points to -the shared record. +the shared record. As an example, suppose we had a many-to-many association of a `Tag` table to `Reminder` via a `ReminderTag` join table: @@ -267,12 +267,12 @@ As an example, suppose we had a many-to-many association of a `Tag` table to `Re ```swift @Table struct Tag: Identifiable { - let id: UUID + let id: UUID var title = "" } @Table struct ReminderTag: Identifiable { - let id: UUID + let id: UUID var reminderID: Reminder.ID var tagID: Tag.ID } @@ -291,7 +291,7 @@ will also not be shared. Sharing these records cannot be done in a consistent an > Note: `CKShare` in CloudKit, which is what our tools are built on, does not support sharing > many-to-many relationships. This is also how the Reminders app works on Apple's platforms. Sharing -> a list of reminders with another use does not share its tags with that user. +> a list of reminders with another use does not share its tags with that user. To see why this is an acceptable limitation, suppose you share a "Personal" list with someone, which holds a "Get milk" reminder, and that reminder has a "weekend" tag associated with it. If the tag @@ -305,8 +305,8 @@ but to turn it into a one-to-many relationship so that each tag belongs to exact ```swift @Table struct Tag: Identifiable { - let id: UUID - var title = "" + let id: UUID + var title = "" var reminderID: Reminder.ID } ``` @@ -320,7 +320,7 @@ In diagrammatic form this schema now looks like the following: This kind of relationship will now be synchronized automatically. Sharing a `RemindersList` will automatically share all of its `Reminder`s, which will subsequently also share all of their -`Tag`s. +`Tag`s. But, this does now mean it's possible to have multiple `Tag` rows in the database that have the same title and thus represent the same tag. You wil have to put extra care in your queries and @@ -387,11 +387,11 @@ To check their permissions for a record, you can join the root record table to ` select the ``SyncMetadata/share`` value: ```swift -let share = try await database.read { db in +let share = try await database.read { db in RemindersList .metadata(for: id) .select(\.share) - .fetchOne(db) + .fetchOne(db) ?? nil } guard @@ -399,7 +399,7 @@ guard || share?.permission == .readWrite else { // User does not have permissions to write to record. - return + return } ``` @@ -412,9 +412,9 @@ suppose that you want reminders lists to be sorted by your user, and so add a `p the table: ```swift -@Table +@Table struct RemindersList: Identifiable { - let id: UUID + let id: UUID var position = 0 var title = "" } @@ -429,15 +429,15 @@ we can use the trick mentioned in ) work just as +such as allowing to unit test your feature's logic, and making it possible to deep link in your +app. The `@FetchAll` property warpper, and other [data fetching tools]() work just as well in an `@Observable` model as they do in a SwiftUI view. The state held in the property wrapper automatically updates when changes are made to the database. @@ -228,11 +228,11 @@ its functionality from scratch: } fetchItems() } - + deinit { NotificationCenter.default.removeObserver(observer) } - + func fetchItems() { do { items = try modelContext.fetch( @@ -248,7 +248,7 @@ its functionality from scratch: } } -> Note: It is necessary to annotate `@FetchAll` with `@ObservationIgnored` when using the +> Note: It is necessary to annotate `@FetchAll` with `@ObservationIgnored` when using the > `@Observable` macro due to how macros interact with property wrappers. However, `@FetchAll` > handles its own observation, and so state will still be observed when accessed in a view. @@ -265,7 +265,7 @@ search for rows in a table: struct ItemsView: View { @State var searchText = "" @FetchAll var items: [Item] - + var body: some View { ForEach(items) { item in Text(item.name) @@ -275,7 +275,7 @@ search for rows in a table: await updateSearchQuery() } } - + func updateSearchQuery() { await $items.load( .fetchAll( @@ -293,7 +293,7 @@ search for rows in a table: // SwiftData struct ItemsView: View { @State var searchText = "" - + var body: some View { SearchResultsView( searchText: searchText @@ -301,18 +301,18 @@ search for rows in a table: .searchable(text: $searchText) } } - + struct SearchResultsView: View { @Query var items: [Item] - + init(searchText: String) { _items = Query( - filter: #Predicate { - $0.title.contains(searchText) + filter: #Predicate { + $0.title.contains(searchText) } ) } - + var body: some View { ForEach(items) { item in Text(item.name) @@ -323,10 +323,10 @@ search for rows in a table: } } -Note that the SwiftData version of this code must have two views. The outer view, `ItemsView`, +Note that the SwiftData version of this code must have two views. The outer view, `ItemsView`, holds onto the `searchText` state that the user can change and uses the `searchable` SwiftUI view modifier. Then, the inner view, `SearchResultsView`, holds onto the `@Query` state so that it can -initialize with a dynamic predicate based on the `searchText`. These two views are necessary +initialize with a dynamic predicate based on the `searchText`. These two views are necessary because `@Query` state is not mutable after it is initialized. The only way to change `@Query` state is if the view holding it is reinitialized, which requires a parent view to recreate the child view. @@ -367,7 +367,7 @@ Then, to create a new row in a table you use the `write` and `insert` methods fr ```swift // SQLiteData @Dependency(\.defaultDatabase) var database - + try database.write { db in try Item.insert(Item(/* ... */)) .execute(db) @@ -378,7 +378,7 @@ Then, to create a new row in a table you use the `write` and `insert` methods fr ```swift // SwiftData @Environment(\.modelContext) var modelContext - + let newItem = Item(/* ... */) modelContext.insert(newItem) try modelContext.save() @@ -393,7 +393,7 @@ To update an existing row you can use the `write` and `update` methods from SQLi ```swift // SQLiteData @Dependency(\.defaultDatabase) var database - + existingItem.title = "Computer" try database.write { db in try Item.update(existingItem).execute(db) @@ -404,7 +404,7 @@ To update an existing row you can use the `write` and `update` methods from SQLi ```swift // SwiftData @Environment(\.modelContext) var modelContext - + existingItem.title = "Computer" try modelContext.save() ``` @@ -418,7 +418,7 @@ And to delete an existing row, you can use the `write` and `delete` methods from ```swift // SQLiteData @Dependency(\.defaultDatabase) var database - + try database.write { db in try Item.delete(existingItem).execute(db) } @@ -428,7 +428,7 @@ And to delete an existing row, you can use the `write` and `delete` methods from ```swift // SwiftData @Environment(\.modelContext) var modelContext - + modelContext.delete(existingItem)) try modelContext.save() ``` @@ -470,11 +470,11 @@ mechanism to work is for `Team` and `Sport` to be classes, and the `@Model` macr Second, because the SQLite execution is so abstracted from us, it makes it easy to execute many, _many_ queries, leading to inefficient code. In this case, we are first executing a query to get all sports, and then executing a query for each sport to get the number of teams in each -sport. And on top of that, we are loading every team into memory just to compute the number of +sport. And on top of that, we are loading every team into memory just to compute the number of teams. We don't actually need any data from the team, only their aggregate count. -SQLiteData does not provide these kinds of tools, and for good reason. Instead, if you know you -want to fetch all of the teams with their corresponding sport, you can simply perform a single +SQLiteData does not provide these kinds of tools, and for good reason. Instead, if you know you +want to fetch all of the teams with their corresponding sport, you can simply perform a single query that joins the two tables together: ```swift @@ -499,12 +499,12 @@ If either of the "sports" or "teams" tables change, this query will be executed state will update to the freshest values. This style of handling associations does require you to be knowledgable in SQL to wield it -correctly, but that is a benefit! SQL (and SQLite) are some of the most proven pieces of +correctly, but that is a benefit! SQL (and SQLite) are some of the most proven pieces of technologies in the history of computers, and knowing how to wield their powers is a huge benefit. ### Booleans and enums -While it may be hard to believe at first, SwiftData does not fully support boolean or enum values +While it may be hard to believe at first, SwiftData does not fully support boolean or enum values for fields of a model. Take for example this following model: ```swift @@ -513,11 +513,11 @@ class Reminder { var isCompleted = false var priority: Priority? init(isCompleted: Bool = false, priority: Priority? = nil) { - self.isCompleted = isCompleted + self.isCompleted = isCompleted self.priority = priority } - enum Priority: Int, Codable { + enum Priority: Int, Codable { case low, medium, high } } @@ -549,7 +549,7 @@ class Reminder { var isCompleted = 0 var priority: Int? init(isCompleted: Int = 0, priority: Int? = nil) { - self.isCompleted = isCompleted + self.isCompleted = isCompleted self.priority = priority } } @@ -571,7 +571,7 @@ On the other hand, booleans and enums work just fine in SQLiteData: struct Reminder { var isCompleted = false var priority: Priority? - enum Priority: Int, QueryBindable { + enum Priority: Int, QueryBindable { case low, medium, high } } @@ -595,7 +595,7 @@ can even leave off the type annotation for `reminders` because it is inferred fr explicit where you make direct changes to the schemas in your database. This includes creating tables, adding, removing or altering columns, adding or removing indices, and more. -Whereas SwiftData has two flavors of migrations. The simplest, "lightweight" migrations, work +Whereas SwiftData has two flavors of migrations. The simplest, "lightweight" migrations, work implicitly by comparing your data types to the database schema and updating the schema accordingly. That cannot always work, and so there are "manual" migrations where you explicitly describe how to change the database schema. @@ -614,7 +614,7 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a var title = "" var isInStock = true } - + migrator.registerMigration("Create 'items' table") { db in try #sql( """ @@ -641,7 +641,7 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a } } -Note that in GRDB we must explicitly create the table, specify its columns, as well as its +Note that in GRDB we must explicitly create the table, specify its columns, as well as its constraints, such as if it is nullable or has a default value. Similarly, adding a column to a data type is also a lightweight migration in SwiftData, such as @@ -657,11 +657,11 @@ adding a `description` field to the `Item` type: var description = "" var isInStock = true } - + migrator.registerMigration("Add 'description' column to 'items'") { db in try #sql( """ - ALTER TABLE "items" + ALTER TABLE "items" ADD COLUMN "description" TEXT """ ) @@ -682,21 +682,21 @@ adding a `description` field to the `Item` type: } } -In each of these cases, the lightweight migration of SwiftData is less code and the actual +In each of these cases, the lightweight migration of SwiftData is less code and the actual migration logic is implicit and hidden away from you. #### Manual migrations However, unfortunately, not all migrations can be "lightweight". In fact, from our experience, -real world apps tend to require complex logic when performing most migrations. Something as simple +real world apps tend to require complex logic when performing most migrations. Something as simple as changing an optional field to be a non-optional field cannot be done as a lightweight migration -since SwiftData does not know what value to insert into the database for any rows with a NULL +since SwiftData does not know what value to insert into the database for any rows with a NULL value. Even adding a unique index to a column is not possible because that may introduce constraint errors if two rows have the same value. -For the times that a lightweight migration is not possible in SwiftData, one must turn to +For the times that a lightweight migration is not possible in SwiftData, one must turn to "manual" migrations via the `VersionedSchema` protocol. As an example, consider adding a unique -index on the "title" column of the "items" table. +index on the "title" column of the "items" table. In GRDB this is a simple two-step process: @@ -705,7 +705,7 @@ In GRDB this is a simple two-step process: incorporate a "#" suffix to differentiate between items with the same name. 1. Add the unique index. -In SwiftData this is a much more involved process since migrations are implicitly tied to the +In SwiftData this is a much more involved process since migrations are implicitly tied to the structure of your data types. The overall steps to follow are as such: 1. Create a type that conforms to the `VersionedSchema` protocol, which represents the current @@ -715,7 +715,7 @@ structure of your data types. The overall steps to follow are as such: 1. Duplicate the entire `@Model` data type so that you can specify the unique index. This type will need a new name so as to not conflict with the current, and so often it is nested in the type created in the previous step. - 1. Because you now have different data types representing `Item` it is customary to add a + 1. Because you now have different data types representing `Item` it is customary to add a type alias that represents the most "current" version of the `Item`. 1. Create a type that conforms to the `SchemaMigrationPlan` which allows you to specify the "stages" that will be executed when a migration is performed. @@ -743,8 +743,8 @@ structure of your data types. The overall steps to follow are as such: // 2️⃣ Create unique index try #sql( """ - CREATE UNIQUE INDEX - "items_title" ON "items" ("title") + CREATE UNIQUE INDEX + "items_title" ON "items" ("title") """ ) .execute(db) @@ -764,7 +764,7 @@ structure of your data types. The overall steps to follow are as such: var isInStock = true } } - + // 2️⃣ Create type to conform to VersionedSchema: enum Schema2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0) @@ -778,19 +778,19 @@ structure of your data types. The overall steps to follow are as such: var isInStock = true } } - + // 4️⃣ Create a type alias for the newest Item schema: typealias Item = Schema2.Item - + // 5️⃣ Create a type to conform to the SchemaMigrationPlan protocol: enum MigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [ - Schema1.self, + Schema1.self, Schema2.self ] } - + // 6️⃣ Create MigrationStage values to implement the logic for migration from one schema // to the next: static var stages: [MigrationStage] { @@ -817,12 +817,12 @@ structure of your data types. The overall steps to follow are as such: } } try context.save() - } didMigrate: { _ in + } didMigrate: { _ in } ] } } - + // 7️⃣ Create ModelContainer with migration plan in entry point of app: @main struct MyApp: App { @@ -845,19 +845,19 @@ Some things to note about the above comparison: with a duplicate title (keeping the first) by using a subquery. * The SwiftData migration is many, many times longer than the equivalent SQLite version involving many intricate steps that are hard to remember and easy to get wrong. - * Because database schemas are tightly coupled to type definitions we have no choice but to + * Because database schemas are tightly coupled to type definitions we have no choice but to duplicate our data type so that we can apply the `@Attribute(.unique)` macro. - * Further, we will need to move all helper methods and computed properties from the previous + * Further, we will need to move all helper methods and computed properties from the previous version of the data type to the new version. * The work in step #6 that deletes items if they have a duplicate titles is very inefficient, but it's not possible to make much more efficient. SwiftData does not provide us with tools to run - raw SQL on the tables, and so we have no choice but to load all of the items into memory and - manually check for unique titles. This is memory intensive and CPU intensive work and may - require extra attention if there are thousands of items in the table. On the other hand, SQLite - can perform this work efficiently on millions of rows without ever loading a single `Item` into + raw SQL on the tables, and so we have no choice but to load all of the items into memory and + manually check for unique titles. This is memory intensive and CPU intensive work and may + require extra attention if there are thousands of items in the table. On the other hand, SQLite + can perform this work efficiently on millions of rows without ever loading a single `Item` into memory. -So, while lightweight migrations are one of the "magical" features of SwiftData, we feel that +So, while lightweight migrations are one of the "magical" features of SwiftData, we feel that complex "manual" migrations are common enough that one should optimize for them rather than the other way around. @@ -869,15 +869,15 @@ with other iCloud users, and it exposes the underlying CloudKit data types (e.g. that you can interact directly with CloudKit if needed. Setting up a database and sync engine in SQLiteData isn't much different from setting up a -SwiftData stack with CloudKit. The main difference is that one must explicitly provide the -container identifier in SQLiteData because SwiftData has been privileged in being able to +SwiftData stack with CloudKit. The main difference is that one must explicitly provide the +container identifier in SQLiteData because SwiftData has been privileged in being able to inspect the Entitlements.plist in order to automatically extract that information: @Row { @Column { ```swift // SQLiteData - @main + @main struct MyApp: App { init() { try! prepareDependencies { @@ -888,7 +888,7 @@ inspect the Entitlements.plist in order to automatically extract that informatio ) } } - + … } ``` @@ -906,7 +906,7 @@ inspect the Entitlements.plist in order to automatically extract that informatio ]) let modelConfiguration = ModelConfiguration(schema: schema) modelContainer = try! ModelContainer( - for: schema, + for: schema, configurations: [modelConfiguration] ) } @@ -932,7 +932,7 @@ SQLiteData has only one of these limitations: schema. For example, if you have a `Tag` table with a unique `title` column, then what are you to do if two different devices create a tag with the title "family" at the same time? * Columns on freshly created tables do not need to have default values or be nullable. Only -newly added columns to existing tables need to either be nullable or have a default. See +newly added columns to existing tables need to either be nullable or have a default. See for more info. * Relationships on freshly created do not need to be nullable. Only newly added columns to existing tables need to be nullable. See for more info. @@ -944,9 +944,9 @@ information about CloudKit synchronization, see . ### Supported Apple platforms -SwiftData and the `@Query` macro require iOS 17, macOS 14, tvOS 17, watchOS 10 and higher, and +SwiftData and the `@Query` macro require iOS 17, macOS 14, tvOS 17, watchOS 10 and higher, and some newer features require even more recent versions of iOS. Meanwhile, SQLiteData has a broad set of deployment targets supporting all the way back to iOS 13, -macOS 10.15, tvOS 13, and watchOS 6. This means you can use these tools on essentially any +macOS 10.15, tvOS 13, and watchOS 6. This means you can use these tools on essentially any application today with no restrictions. diff --git a/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md index 57cb4154..a875f2b5 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md @@ -40,7 +40,7 @@ It fetches _all_ items from the backing database into memory and then Swift does filtering, sorting, and truncating this data before it is displayed to the user. This means if the table contains thousands, or even hundreds of thousands of rows, every single one will be loaded into memory and processed, which is incredibly inefficient to do. Worse, this work will be performed -every single time `displayedItems` is evaluated, which will be at least once for each time the +every single time `displayedItems` is evaluated, which will be at least once for each time the view's body is computed, but could also be more. This kind of data processing is exactly what SQLite excels at, and so we can offload this work by @@ -87,5 +87,5 @@ struct ContentView: View { > Important: If a parent view refreshes, a dynamically-updated query can be overwritten with the > initial `@FetchAll`'s value, taken from the parent. To manage the state of this dynamic query -> locally to this view, we use `@State @FetchAll`, instead, and to access the underlying +> locally to this view, we use `@State @FetchAll`, instead, and to access the underlying > `FetchAll` value you can use `wrappedValue`. diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md index 4f65e426..acb51ded 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md @@ -16,17 +16,17 @@ queries in a single transaction. ### @FetchAll The [`@FetchAll`]() property wrapper allows you to fetch a collection of results from -your database using a SQL query. The query is created using our +your database using a SQL query. The query is created using our [StructuredQueries][structured-queries-gh] library, which can build type-safe queries that safely and performantly decode into Swift data types. -To get access to these tools you must apply the `@Table` macro to your data type that represents +To get access to these tools you must apply the `@Table` macro to your data type that represents your table: ```swift @Table struct Reminder { - let id: Int + let id: Int var title = "" var dueAt: Date? var isCompleted = false @@ -40,15 +40,15 @@ simply doing: @FetchAll var reminders: [Reminder] ``` -If you want to execute a more complex query, such as one that sorts the results by the reminder's -title, then you can use the various query building APIs on `Reminder`: +If you want to execute a more complex query, such as one that sorts the results by the reminder's +title, then you can use the various query building APIs on `Reminder`: ```swift @FetchAll(Reminder.order(by: \.title)) var reminders ``` -Or if you want to only select the completed reminders, sorted by their titles in a descending +Or if you want to only select the completed reminders, sorted by their titles in a descending fashion: ```swift @@ -68,7 +68,7 @@ You can even execute a SQL string to populate the data in your features: var completedReminders: [Reminder] ``` -This uses the `#sql` macro for constructing [safe SQL strings][sq-safe-sql-strings]. You are +This uses the `#sql` macro for constructing [safe SQL strings][sq-safe-sql-strings]. You are automatically protected from SQL injection attacks, and it is even possible to use the static description of your schema to prevent accidental typos: @@ -97,7 +97,7 @@ exactly one list: ```swift @Table struct Reminder { - let id: Int + let id: Int var title = "" var dueAt: Date? var isCompleted = false @@ -105,7 +105,7 @@ struct Reminder { } @Table struct RemindersList: Identifiable { - let id: Int + let id: Int var title = "" } ``` @@ -123,7 +123,7 @@ struct Record { } ``` -And then we construct a query that joins the `Reminder` table to the `RemindersList` table and +And then we construct a query that joins the `Reminder` table to the `RemindersList` table and selects the titles from each table: ```swift @@ -132,7 +132,7 @@ selects the titles from each table: .join(RemindersList.all) { $0.remindersListID.eq($1.id) } .select { Record.Columns( - reminderTitle: $0.title, + reminderTitle: $0.title, remindersListTitle: $1.title ) } @@ -219,9 +219,9 @@ Here we have defined a ``FetchKeyRequest/Value`` type inside the conformance tha data we want to query for in a single transaction, and then we can construct it and return it from the ``FetchKeyRequest/fetch(_:)`` method. -With this conformance defined we can use the +With this conformance defined we can use the [`@Fetch`]() property wrapper to execute the query specified by -the `Reminders` type, and we can access the `completedReminders` and `remindersCount` properties +the `Reminders` type, and we can access the `completedReminders` and `remindersCount` properties to get to the queried data: ```swift diff --git a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md index 87e8925e..55edfbcd 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md @@ -9,7 +9,7 @@ simplify the library, and make it more powerful. As such, we often need to depre in favor of newer ones. We recommend people update their code as quickly as possible to the newest APIs, and these guides contain tips to do so. -> Important: Before following any particular migration guide be sure you have followed all the +> Important: Before following any particular migration guide be sure you have followed all the > preceding migration guides. ## Topics diff --git a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md index b3b3bf6d..d8a445e5 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md @@ -15,7 +15,7 @@ APIs, and these guides contain tips to do so. ## @FetchAll, @FetchOne, @Fetch -SQLiteData 0.2.0 comes with 3 brand new property wrappers that largely replace the need for +SQLiteData 0.2.0 comes with 3 brand new property wrappers that largely replace the need for SwiftData and its `@Query` macro. In 0.1.0, one would perform queries as either a hard coded SQL string: @@ -39,7 +39,7 @@ struct CompletedReminders: FetchKeyRequest { var completedReminders ``` -Each of these are cumbersome, and version 0.2.0 of SQLiteData fixes things thanks to our newly +Each of these are cumbersome, and version 0.2.0 of SQLiteData fixes things thanks to our newly released [StructuredQueries][] library. You can now describe the query for your data in a type-safe manner, and directly inline: @@ -57,14 +57,14 @@ Read for more information on how to use these new property wrappe The [`.fetchAll`](), [`.fetchOne`](), and [`.fetch`]() APIs have been soft-deprecated -in favor of the more modern tools described above and in . They will be hard +in favor of the more modern tools described above and in . They will be hard deprecated in a future release of SQLiteData, and removed in 1.0. ## Avoiding the cost of macros -SQLiteData introduces a macro in version 0.2.0 (in particular, the `@Table` macro), and +SQLiteData introduces a macro in version 0.2.0 (in particular, the `@Table` macro), and unfortunately macros currently come with an unfortunate cost in that you have to compile SwiftSyntax -from scratch, which can take time. If the cost of macros is too high for you, then you can depend -on the SQLiteDataCore module instead of the full SQLiteData module. This will give you access to +from scratch, which can take time. If the cost of macros is too high for you, then you can depend +on the SQLiteDataCore module instead of the full SQLiteData module. This will give you access to only a subset of tools provided by SQLiteData, but you will have access to all tools that were available in version 0.1.0 of the library. diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Observing.md b/Sources/SQLiteData/Documentation.docc/Articles/Observing.md index d0bc43a3..c60d20d1 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/Observing.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Observing.md @@ -10,9 +10,9 @@ macro from SwiftData. ### SwiftUI -The [`@FetchAll`](), [`@FetchOne`](), and [`@Fetch`]() -property wrappers work in SwiftUI views similarly to how the `@Query` macro does from SwiftData. -You simply add a property to the view that is annotated with one of the various ways of +The [`@FetchAll`](), [`@FetchOne`](), and [`@Fetch`]() +property wrappers work in SwiftUI views similarly to how the `@Query` macro does from SwiftData. +You simply add a property to the view that is annotated with one of the various ways of [querying your database](): ```swift @@ -66,15 +66,15 @@ then you can do roughly the following: ```swift class ItemsViewController: UICollectionViewController { @FetchAll var items: [Item] - + override func viewDidLoad() { // Set up data source and cell registration... - + // Observe changes to items in order to update data source: - $items.publisher.sink { items in + $items.publisher.sink { items in guard let self else { return } dataSource.apply( - NSDiffableDataSourceSnapshot(items: items), + NSDiffableDataSourceSnapshot(items: items), animatingDifferences: true ) } @@ -86,20 +86,20 @@ class ItemsViewController: UICollectionViewController { This uses the `publisher` property that is available on every fetched value to update the collection view's data source whenever the `items` change. -> Tip: There is an alternative way to observe changes to `items`. If you are already depending on -> our [Swift Navigation][swift-nav-gh] library to make use of powerful navigation APIs for SwiftUI +> Tip: There is an alternative way to observe changes to `items`. If you are already depending on +> our [Swift Navigation][swift-nav-gh] library to make use of powerful navigation APIs for SwiftUI > and UIKitNavigation, then you can use the [`observe`][observe-docs] tool to update the database > without using Combine: -> +> > ```swift > override func viewDidLoad() { > // Set up data source and cell registration... -> +> > // Observe changes to items in order to update data source: > observe { [weak self] in > guard let self else { return } > dataSource.apply( -> NSDiffableDataSourceSnapshot(items: items), +> NSDiffableDataSourceSnapshot(items: items), > animatingDifferences: true > ) > } diff --git a/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md index 3b447f3f..1239280b 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md @@ -4,7 +4,7 @@ Learn how to create and configure the SQLite database that holds your applicatio ## Overview -Before you can use any of the tools of this library you must create and configure the SQLite +Before you can use any of the tools of this library you must create and configure the SQLite database that will be used throughout the app. There are a few steps to getting this right, and a few optional steps you can perform to make the database you provision work well for testing and Xcode previews. @@ -35,7 +35,7 @@ func appDatabase() -> any DatabaseWriter { ### Step 2: Create configuration Inside this static variable we can create a [`Configuration`][config-docs] value that is used to -configure the database. We recommend turning on +configure the database. We recommend turning on [foreign key](https://www.sqlite.org/foreignkeys.html) constraints to protect the integrity of your data: @@ -47,9 +47,9 @@ data: ``` > Important: If you are synchronizing your database to CloudKit, then you must not enable -> foreign keys. See for more information. +> foreign keys. See for more information. -This will prevent you from deleting rows that leave other rows with invalid associations. For +This will prevent you from deleting rows that leave other rows with invalid associations. For example, if a "reminders" table had an association to a "remindersLists" table, you would not be allowed to delete a list row unless there were no reminders associated with it, or if you had specified a cascading action (such as delete). @@ -129,7 +129,7 @@ way to do this is to construct the database connection for a path on the file sy } ``` -However, this can be improved. First, this code will crash if it is executed in Xcode previews +However, this can be improved. First, this code will crash if it is executed in Xcode previews because SQLite is unable to form a connection to a database on disk in a preview context. And second, in tests we should write this databadse to the temporary directoy on disk with a unique name so that each test gets a fresh database and so that multiple tests can run in parallel. @@ -174,7 +174,7 @@ context or if we're in a preview or test. ### Step 4: Migrate database -Now that the database connection is created we can migrate the database. GRDB provides all the +Now that the database connection is created we can migrate the database. GRDB provides all the tools necessary to perform [database migrations][grdb-migration-docs], but the basics include creating a `DatabaseMigrator`, registering migrations with it, and then using it to migrate the database connection: @@ -220,9 +220,9 @@ database connection: As your application evolves you will register more and more migrations with the migrator. -It is up to you how you want to actually execute the SQL that creates your tables. There are -[APIs in the community][grdb-table-definition] for building table definition statements using Swift -code, but we personally feel that it is simpler, more flexible and more powerful to use +It is up to you how you want to actually execute the SQL that creates your tables. There are +[APIs in the community][grdb-table-definition] for building table definition statements using Swift +code, but we personally feel that it is simpler, more flexible and more powerful to use [plain SQL strings][table-definition-tools]: [grdb-table-definition]: https://swiftpackageindex.com/groue/grdb.swift/v7.6.1/documentation/grdb/database/create(table:options:body:) @@ -252,7 +252,7 @@ migrator.registerMigration("Create tables") { db in It may seem counterintuitive that we recommend using SQL strings for table definitions when so much of the library provides type-safe and schema-safe tools for executing SQL. But table definition SQL is fundamentally different from other SQL as it is frozen in time and should never be edited -after it has been deployed to users. Read [this article][table-definition-tools] from our +after it has been deployed to users. Read [this article][table-definition-tools] from our StructuredQueries library to learn more about this decision. [table-definition-tools]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/main/documentation/structuredqueriescore/definingyourschema#Table-definition-tools @@ -319,7 +319,7 @@ import SwiftUI @main struct MyApp: App { init() { - prepareDependencies { + prepareDependencies { $0.defaultDatabase = try! appDatabase() } } @@ -354,7 +354,7 @@ It is also important to prepare the database in Xcode previews. This can be done ```swift #Preview { - let _ = prepareDependencies { + let _ = prepareDependencies { $0.defaultDatabase = try! appDatabase() } // ... diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index a2d1f38e..4bf75257 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -85,11 +85,11 @@ in SwiftData: // SwiftData @main struct MyApp: App { - let container = { + let container = { // Create/configure a container try! ModelContainer(/* ... */) }() - + var body: some Scene { WindowGroup { ContentView() @@ -148,7 +148,7 @@ a model context, via a property wrapper: ```swift // SQLiteData @Dependency(\.defaultDatabase) var database - + try database.write { db in try Item.insert(Item(/* ... */)) .execute(db) @@ -159,7 +159,7 @@ a model context, via a property wrapper: ```swift // SwiftData @Environment(\.modelContext) var modelContext - + let newItem = Item(/* ... */) modelContext.insert(newItem) try modelContext.save() @@ -171,7 +171,7 @@ a model context, via a property wrapper: > . Further, if you want to synchronize the local database to CloudKit so that it is available on -all your user's devices, simply configure a `SyncEngine` in the entry point of the app: +all your user's devices, simply configure a `SyncEngine` in the entry point of the app: ```swift @main @@ -222,7 +222,7 @@ Orders.fetchAll setup rampup duration ## SQLite knowledge required -SQLite is one of the +SQLite is one of the [most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of software in the history of software. Knowledge of SQLite is a great skill for any app developer to have, and this library does not want to conceal it from you. So, we feel that to best wield this diff --git a/Tests/SQLiteDataTests/AssertQueryTests.swift b/Tests/SQLiteDataTests/AssertQueryTests.swift index 99285ca6..a09e9c9c 100644 --- a/Tests/SQLiteDataTests/AssertQueryTests.swift +++ b/Tests/SQLiteDataTests/AssertQueryTests.swift @@ -2,9 +2,9 @@ import Dependencies import DependenciesTestSupport import Foundation import GRDB -import Sharing import SQLiteData import SQLiteDataTestSupport +import Sharing import SnapshotTesting import StructuredQueries import Testing diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index 2c70e50c..8167af15 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -533,13 +533,12 @@ extension BaseCloudKitTests { } let metadata = - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1).fetchOne(db) - } + 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, *) @Test func addAndRemoveFunctions() async throws { let query = #sql( @@ -718,8 +717,10 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() expectNoDifference( - try { try userDatabase.userRead { db in - try RemindersList.find(1).fetchOne(db) } + try { + try userDatabase.userRead { db in + try RemindersList.find(1).fetchOne(db) + } }(), RemindersList(id: 1, title: "Work") ) @@ -1045,7 +1046,6 @@ extension BaseCloudKitTests { """ } - let record = try syncEngine.private.database.record(for: ModelA.recordID(for: 1)) record.encryptedValues["isEven"] = false try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index 07692787..47915f04 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -280,18 +280,18 @@ extension BaseCloudKitTests { """ } - 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) - ] - ) - } + 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, *) @@ -369,25 +369,25 @@ extension BaseCloudKitTests { 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)) + 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 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 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")) - } + 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) @@ -433,10 +433,10 @@ extension BaseCloudKitTests { """ } - 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)) - } + 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. @@ -452,14 +452,14 @@ extension BaseCloudKitTests { ) personalListRecord.setValue(1, forKey: "id", at: now) personalListRecord.setValue("Personal", forKey: "title", at: now) - + let businessListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 2) ) businessListRecord.setValue(2, forKey: "id", at: now) businessListRecord.setValue("Business", forKey: "title", at: now) - + let reminderRecord = CKRecord( recordType: Reminder.tableName, recordID: Reminder.recordID(for: 1) @@ -471,12 +471,12 @@ extension BaseCloudKitTests { record: personalListRecord, action: .none ) - + try await syncEngine.modifyRecords( scope: .private, saving: [reminderRecord, personalListRecord] ).notify() - + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -509,7 +509,7 @@ extension BaseCloudKitTests { ) """ } - + let modifications = try await withDependencies { $0.datetime.now.addTimeInterval(1) } operation: { @@ -518,7 +518,7 @@ extension BaseCloudKitTests { ) reminderRecord.setValue(2, forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference(record: businessListRecord, action: .none) - + let modifications = try syncEngine.modifyRecords( scope: .private, saving: [businessListRecord] @@ -526,9 +526,9 @@ extension BaseCloudKitTests { try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() return modifications } - + await modifications.notify() - + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -569,11 +569,11 @@ extension BaseCloudKitTests { ) """ } - + 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) @@ -622,14 +622,14 @@ extension BaseCloudKitTests { 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 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) @@ -653,14 +653,14 @@ extension BaseCloudKitTests { """ } - 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 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)) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -701,14 +701,14 @@ extension BaseCloudKitTests { 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)) - } + 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() @@ -754,14 +754,14 @@ extension BaseCloudKitTests { """ } - 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)) - } + 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 ea391dd9..85cfaa80 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift @@ -247,7 +247,7 @@ extension BaseCloudKitTests { try await userDatabase.userRead { db in let reminderMetadata = - try SyncMetadata + try SyncMetadata .where { $0.parentRecordType == RemindersList.tableName } .fetchAll(db) #expect( @@ -275,7 +275,7 @@ extension BaseCloudKitTests { try await userDatabase.userRead { db in let reminderMetadata = - try SyncMetadata + try SyncMetadata .where { $0.parentRecordPrimaryKey.eq("1") } .fetchAll(db) #expect( diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index 9a06ce0a..73750e4c 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -123,7 +123,8 @@ extension BaseCloudKitTests { @Test func saveInUnknownZone() async throws { let record = CKRecord( recordType: "Record", - recordID: CKRecord.ID(recordName: "Record", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) + recordID: CKRecord.ID( + recordName: "Record", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) ) let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( @@ -307,7 +308,9 @@ extension BaseCloudKitTests { } #expect(error == CKError(.accountTemporarilyUnavailable)) error = await #expect(throws: CKError.self) { - _ = try await self.syncEngine.private.database.records(for: [CKRecord.ID(recordName: "test")]) + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) } #expect(error == CKError(.accountTemporarilyUnavailable)) } @@ -328,7 +331,9 @@ extension BaseCloudKitTests { } #expect(error == CKError(.notAuthenticated)) error = await #expect(throws: CKError.self) { - _ = try await self.syncEngine.private.database.records(for: [CKRecord.ID(recordName: "test")]) + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) } #expect(error == CKError(.notAuthenticated)) } @@ -349,7 +354,9 @@ extension BaseCloudKitTests { } #expect(error == CKError(.notAuthenticated)) error = await #expect(throws: CKError.self) { - _ = try await self.syncEngine.private.database.records(for: [CKRecord.ID(recordName: "test")]) + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) } #expect(error == CKError(.notAuthenticated)) } @@ -370,7 +377,9 @@ extension BaseCloudKitTests { } #expect(error == CKError(.notAuthenticated)) error = await #expect(throws: CKError.self) { - _ = try await self.syncEngine.private.database.records(for: [CKRecord.ID(recordName: "test")]) + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) } #expect(error == CKError(.notAuthenticated)) } @@ -385,7 +394,7 @@ extension BaseCloudKitTests { } matching: { issue in issue.description == """ Issue recorded: A new identity was created for an existing 'CKRecord' ('1'). Rather than \ - creating 'CKRecord' from scratch for an existing record, use the database to fetch the \ + creating 'CKRecord' from scratch for an existing record, use the database to fetch the \ current record. """ } diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index e00d74d2..992362ab 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -499,8 +499,11 @@ extension BaseCloudKitTests { let remindersTableIndex = try #require( recordTypesAfterMigration.firstIndex { $0.tableName == Reminder.tableName } ) - #expect(recordTypes[0.. DatabasePool { """ ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TABLE "parents"( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL ) STRICT - """) + """ + ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TABLE "childWithOnDeleteSetNulls"( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT - """) + """ + ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TABLE "childWithOnDeleteSetDefaults"( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "parentID" INTEGER NOT NULL DEFAULT 0 REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT - """) + """ + ) .execute(db) try #sql( """ @@ -174,35 +180,43 @@ func database(containerIdentifier: String) throws -> DatabasePool { """ ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TABLE "modelAs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) - """) + """ + ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TABLE "modelBs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) - """) + """ + ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TABLE "modelCs" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) - """) + """ + ) .execute(db) - try #sql(""" + try #sql( + """ CREATE TABLE "unsyncedModels" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL ) - """) + """ + ) .execute(db) } return database diff --git a/Tests/SQLiteDataTests/SharingGRDBTests.swift b/Tests/SQLiteDataTests/SharingGRDBTests.swift index adcfaa9e..c0916180 100644 --- a/Tests/SQLiteDataTests/SharingGRDBTests.swift +++ b/Tests/SQLiteDataTests/SharingGRDBTests.swift @@ -1,8 +1,8 @@ import Dependencies import DependenciesTestSupport import GRDB -import Sharing import SQLiteData +import Sharing import StructuredQueries import SwiftUI import Testing From 0507aa5a9cb3ba4cc7f1b5438471e1e240344911 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:07:00 -0700 Subject: [PATCH 518/581] wip --- Sources/SQLiteData/Fetch.swift | 2 ++ Sources/SQLiteData/FetchAll.swift | 2 ++ Sources/SQLiteData/FetchOne.swift | 2 ++ Sources/SQLiteData/Internal/Exports.swift | 1 - 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index dd40d9d8..5f916314 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -1,3 +1,5 @@ +import Sharing + #if canImport(Combine) import Combine #endif diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 6dff41f2..18239778 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -1,3 +1,5 @@ +import Sharing + #if canImport(Combine) import Combine #endif diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 9313a850..e5e58a25 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -1,3 +1,5 @@ +import Sharing + #if canImport(Combine) import Combine #endif diff --git a/Sources/SQLiteData/Internal/Exports.swift b/Sources/SQLiteData/Internal/Exports.swift index af0d5981..e59b61a1 100644 --- a/Sources/SQLiteData/Internal/Exports.swift +++ b/Sources/SQLiteData/Internal/Exports.swift @@ -1,5 +1,4 @@ @_exported import Dependencies -@_exported import Sharing @_exported import StructuredQueriesSQLite @_exported import struct GRDB.Configuration From 5801e1e8818c1d7313fdce03958a4b02ee92aa9c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:13:19 -0700 Subject: [PATCH 519/581] wip --- Tests/SQLiteDataTests/AssertQueryTests.swift | 4 - .../CloudKitTests/AccountLifecycleTests.swift | 221 +- .../CloudKitTests/AssetsTests.swift | 300 +- .../CloudKitTests/CloudKitTests.swift | 1956 ++++++------- .../FetchRecordZoneChangesTests.swift | 914 +++--- .../FetchedDatabaseChangesTests.swift | 274 +- .../ForeignKeyConstraintTests.swift | 1474 +++++----- .../CloudKitTests/MergeConflictTests.swift | 1311 ++++----- .../CloudKitTests/MetadataTests.swift | 510 ++-- .../MockCloudDatabaseTests.swift | 780 +++--- .../CloudKitTests/NewTableSyncTests.swift | 210 +- .../NextRecordZoneChangeBatchTests.swift | 382 +-- .../CloudKitTests/RecordTypeTests.swift | 1037 +++---- .../ReferenceViolationTests.swift | 632 ++--- .../CloudKitTests/SchemaChangeTests.swift | 574 ++-- .../SharingPermissionsTests.swift | 816 +++--- .../CloudKitTests/SharingTests.swift | 1584 +++++------ .../SyncEngineLifecycleTests.swift | 756 ++--- .../CloudKitTests/SyncEngineTests.swift | 170 +- .../SyncEngineValidationTests.swift | 576 ++-- .../CloudKitTests/TriggerTests.swift | 2482 +++++++++-------- .../CloudKitTests/UserlandTests.swift | 54 +- .../SQLiteDataTests/CustomFunctionTests.swift | 1 - Tests/SQLiteDataTests/FetchAllTests.swift | 5 - Tests/SQLiteDataTests/FetchOneTests.swift | 5 - Tests/SQLiteDataTests/FetchTests.swift | 5 +- Tests/SQLiteDataTests/IntegrationTests.swift | 4 +- Tests/SQLiteDataTests/MigrationTests.swift | 1 - Tests/SQLiteDataTests/QueryCursorTests.swift | 1 - Tests/SQLiteDataTests/SharingGRDBTests.swift | 123 - 30 files changed, 8534 insertions(+), 8628 deletions(-) delete mode 100644 Tests/SQLiteDataTests/SharingGRDBTests.swift diff --git a/Tests/SQLiteDataTests/AssertQueryTests.swift b/Tests/SQLiteDataTests/AssertQueryTests.swift index a09e9c9c..17179471 100644 --- a/Tests/SQLiteDataTests/AssertQueryTests.swift +++ b/Tests/SQLiteDataTests/AssertQueryTests.swift @@ -1,12 +1,8 @@ -import Dependencies import DependenciesTestSupport import Foundation -import GRDB import SQLiteData import SQLiteDataTestSupport -import Sharing import SnapshotTesting -import StructuredQueries import Testing @Suite( diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index f9f18a31..ad47b160 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -1,129 +1,130 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - final class AccountLifecycleTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func signOutClearsUserDatabaseAndMetadatabase() async throws { - try await userDatabase.userWrite { 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) + extension BaseCloudKitTests { + @MainActor + final class AccountLifecycleTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func signOutClearsUserDatabaseAndMetadatabase() async throws { + try await userDatabase.userWrite { 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) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await signOut() + 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) - } - }() - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test(.accountStatus(.noAccount)) func signInUploadsLocalRecordsToCloudKit() async throws { - try await userDatabase.userWrite { 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) - } + 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 { - 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) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.accountStatus(.noAccount)) func signInUploadsLocalRecordsToCloudKit() async throws { + try await userDatabase.userWrite { 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) + } } - }() - await signIn() + 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 syncEngine.processPendingDatabaseChanges(scope: .private) - 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: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), - recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - position: 0, - remindersListID: 1 - ), - [2]: 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) + 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:remindersListPrivates/zone/__defaultOwner__), + recordType: "remindersListPrivates", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + position: 0, + remindersListID: 1 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } } - } - @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) + @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) + } } } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func doNotUploadExistingDataToCloudKitWhenSignedOut() { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func doNotUploadExistingDataToCloudKitWhenSignedOut() { + } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift index 58e9a87c..bd3c1680 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift @@ -1,172 +1,174 @@ -import CloudKit -import ConcurrencyExtras -import CustomDump -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - final class AssetsTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.dataManager) var dataManager - var inMemoryDataManager: InMemoryDataManager { - dataManager as! InMemoryDataManager - } + extension BaseCloudKitTests { + @MainActor + final class AssetsTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.dataManager) var dataManager + var inMemoryDataManager: InMemoryDataManager { + dataManager as! InMemoryDataManager + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func basics() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersListAsset(id: 1, coverImage: Data("image".utf8), remindersListID: 1) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func basics() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersListAsset(id: 1, coverImage: Data("image".utf8), remindersListID: 1) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), - recordType: "remindersListAssets", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - remindersListID: 1, - coverImage: CKAsset( - fileURL: URL(file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d), - dataString: "image" + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), + recordType: "remindersListAssets", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 1, + coverImage: CKAsset( + fileURL: URL(file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d), + dataString: "image" + ) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" ) - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } + """ + } - inMemoryDataManager.storage.withValue { storage in - let url = URL( - string: "file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" - )! - #expect(storage[url] == Data("image".utf8)) - } + inMemoryDataManager.storage.withValue { storage in + let url = URL( + string: "file:///6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + )! + #expect(storage[url] == Data("image".utf8)) + } - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - try await userDatabase.userWrite { db in - try RemindersListAsset - .find(1) - .update { $0.coverImage = Data("new-image".utf8) } - .execute(db) + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersListAsset + .find(1) + .update { $0.coverImage = Data("new-image".utf8) } + .execute(db) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), - recordType: "remindersListAssets", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - remindersListID: 1, - coverImage: CKAsset( - fileURL: URL(file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf), - dataString: "new-image" + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), + recordType: "remindersListAssets", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 1, + coverImage: CKAsset( + fileURL: URL(file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf), + dataString: "new-image" + ) + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" ) - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } + """ + } - inMemoryDataManager.storage.withValue { storage in - let url = URL( - string: "file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf" - )! - #expect(storage[url] == Data("new-image".utf8)) + inMemoryDataManager.storage.withValue { storage in + let url = URL( + string: "file:///97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf" + )! + #expect(storage[url] == Data("new-image".utf8)) + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func receiveAsset() 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) - - let remindersListAssetRecord = CKRecord( - recordType: RemindersListAsset.tableName, - recordID: RemindersListAsset.recordID(for: 1) - ) - remindersListAssetRecord.setValue("1", forKey: "id", at: now) - remindersListAssetRecord.setValue( - Array("image".utf8), - forKey: "coverImage", - at: now - ) - remindersListAssetRecord.setValue( - "1", - forKey: "remindersListID", - at: now - ) - remindersListAssetRecord.parent = CKRecord.Reference( - record: remindersListRecord, - action: .none - ) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveAsset() 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: [remindersListAssetRecord, remindersListRecord] - ) - .notify() + let remindersListAssetRecord = CKRecord( + recordType: RemindersListAsset.tableName, + recordID: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setValue("1", forKey: "id", at: now) + remindersListAssetRecord.setValue( + Array("image".utf8), + forKey: "coverImage", + at: now + ) + remindersListAssetRecord.setValue( + "1", + forKey: "remindersListID", + at: now + ) + remindersListAssetRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) - try await userDatabase.read { db in - let remindersListAsset = try #require( - try RemindersListAsset.find(1).fetchOne(db) + try await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord, remindersListRecord] ) - #expect(remindersListAsset.coverImage == Data("image".utf8)) + .notify() + + try await userDatabase.read { db in + let remindersListAsset = try #require( + try RemindersListAsset.find(1).fetchOne(db) + ) + #expect(remindersListAsset.coverImage == Data("image".utf8)) + } } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index 8167af15..1717e31e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -1,812 +1,1025 @@ -import CloudKit -import ConcurrencyExtras -import CustomDump -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func setUp() throws { - let zones = try userDatabase.userRead { db in - try RecordType.all.fetchAll(db) - } - assertInlineSnapshot(of: zones, as: .customDump) { - #""" - [ - [0]: RecordType( - tableName: "remindersLists", - schema: """ - CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, - name: "title", - notNull: true, - type: "TEXT" - ) - ] - ), - [1]: RecordType( - tableName: "sqlite_sequence", - schema: "CREATE TABLE sqlite_sequence(name,seq)", - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "name", - notNull: false, - type: "" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "seq", - notNull: false, - type: "" - ) - ] - ), - [2]: RecordType( - tableName: "remindersListAssets", - schema: """ - CREATE TABLE "remindersListAssets" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "coverImage" BLOB NOT NULL, - "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "coverImage", - notNull: true, - type: "BLOB" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [2]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "remindersListID", - notNull: true, - type: "INTEGER" - ) - ] - ), - [3]: RecordType( - tableName: "remindersListPrivates", - schema: """ - CREATE TABLE "remindersListPrivates" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "0", - isPrimaryKey: false, - name: "position", - notNull: true, - type: "INTEGER" - ), - [2]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "remindersListID", - notNull: true, - type: "INTEGER" - ) - ] - ), - [4]: RecordType( - tableName: "reminders", - schema: """ - CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "dueDate" TEXT, - "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "priority" INTEGER, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "remindersListID" INTEGER NOT NULL, - - FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "dueDate", - notNull: false, - type: "TEXT" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [2]: TableInfo( - defaultValue: "0", - isPrimaryKey: false, - name: "isCompleted", - notNull: true, - type: "INTEGER" - ), - [3]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "priority", - notNull: false, - type: "INTEGER" - ), - [4]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "remindersListID", - notNull: true, - type: "INTEGER" - ), - [5]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, - name: "title", - notNull: true, - type: "TEXT" - ) - ] - ), - [5]: RecordType( - tableName: "tags", - schema: """ - CREATE TABLE "tags" ( - "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "title", - notNull: true, - type: "TEXT" - ) - ] - ), - [6]: RecordType( - tableName: "reminderTags", - schema: """ - CREATE TABLE "reminderTags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, - "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "reminderID", - notNull: true, - type: "INTEGER" - ), - [2]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "tagID", - notNull: true, - type: "TEXT" - ) - ] - ), - [7]: RecordType( - tableName: "parents", - schema: """ - CREATE TABLE "parents"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ) - ] - ), - [8]: RecordType( - tableName: "childWithOnDeleteSetNulls", - schema: """ - CREATE TABLE "childWithOnDeleteSetNulls"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "parentID", - notNull: false, - type: "INTEGER" - ) - ] - ), - [9]: RecordType( - tableName: "childWithOnDeleteSetDefaults", - schema: """ - CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "parentID" INTEGER NOT NULL DEFAULT 0 - REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "0", - isPrimaryKey: false, - name: "parentID", - notNull: true, - type: "INTEGER" - ) - ] - ), - [10]: RecordType( - tableName: "localUsers", - schema: """ - CREATE TABLE "localUsers" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, - name: "name", - notNull: true, - type: "TEXT" - ), - [2]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "parentID", - notNull: false, - type: "INTEGER" - ) - ] - ), - [11]: RecordType( - tableName: "modelAs", - schema: """ - CREATE TABLE "modelAs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL - ) - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: "0", - isPrimaryKey: false, - name: "count", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ) - ] - ), - [12]: RecordType( - tableName: "modelBs", - schema: """ - CREATE TABLE "modelBs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE - ) - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "0", - isPrimaryKey: false, - name: "isOn", - notNull: true, - type: "INTEGER" - ), - [2]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "modelAID", - notNull: true, - type: "INTEGER" - ) - ] - ), - [13]: RecordType( - tableName: "modelCs", - schema: """ - CREATE TABLE "modelCs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE - ) - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "modelBID", - notNull: true, - type: "INTEGER" - ), - [2]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, - name: "title", - notNull: true, - type: "TEXT" - ) - ] - ), - [14]: RecordType( - tableName: "unsyncedModels", - schema: """ - CREATE TABLE "unsyncedModels" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL - ) - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ) - ] - ) - ] - """# + extension BaseCloudKitTests { + @MainActor + final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func setUp() throws { + let zones = try userDatabase.userRead { db in + try RecordType.all.fetchAll(db) + } + assertInlineSnapshot(of: zones, as: .customDump) { + #""" + [ + [0]: RecordType( + tableName: "remindersLists", + schema: """ + CREATE TABLE "remindersLists" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] + ), + [1]: RecordType( + tableName: "sqlite_sequence", + schema: "CREATE TABLE sqlite_sequence(name,seq)", + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "name", + notNull: false, + type: "" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "seq", + notNull: false, + type: "" + ) + ] + ), + [2]: RecordType( + tableName: "remindersListAssets", + schema: """ + CREATE TABLE "remindersListAssets" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "coverImage" BLOB NOT NULL, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "coverImage", + notNull: true, + type: "BLOB" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "INTEGER" + ) + ] + ), + [3]: RecordType( + tableName: "remindersListPrivates", + schema: """ + CREATE TABLE "remindersListPrivates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "position", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "INTEGER" + ) + ] + ), + [4]: RecordType( + tableName: "reminders", + schema: """ + CREATE TABLE "reminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "dueDate" TEXT, + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "priority" INTEGER, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "remindersListID" INTEGER NOT NULL, + + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "dueDate", + notNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isCompleted", + notNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "priority", + notNull: false, + type: "INTEGER" + ), + [4]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "INTEGER" + ), + [5]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] + ), + [5]: RecordType( + tableName: "tags", + schema: """ + CREATE TABLE "tags" ( + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "title", + notNull: true, + type: "TEXT" + ) + ] + ), + [6]: RecordType( + tableName: "reminderTags", + schema: """ + CREATE TABLE "reminderTags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "reminderID", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "tagID", + notNull: true, + type: "TEXT" + ) + ] + ), + [7]: RecordType( + tableName: "parents", + schema: """ + CREATE TABLE "parents"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ) + ] + ), + [8]: RecordType( + tableName: "childWithOnDeleteSetNulls", + schema: """ + CREATE TABLE "childWithOnDeleteSetNulls"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "INTEGER" + ) + ] + ), + [9]: RecordType( + tableName: "childWithOnDeleteSetDefaults", + schema: """ + CREATE TABLE "childWithOnDeleteSetDefaults"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL DEFAULT 0 + REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "parentID", + notNull: true, + type: "INTEGER" + ) + ] + ), + [10]: RecordType( + tableName: "localUsers", + schema: """ + CREATE TABLE "localUsers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "name", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "INTEGER" + ) + ] + ), + [11]: RecordType( + tableName: "modelAs", + schema: """ + CREATE TABLE "modelAs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "count", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ) + ] + ), + [12]: RecordType( + tableName: "modelBs", + schema: """ + CREATE TABLE "modelBs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isOn", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelAID", + notNull: true, + type: "INTEGER" + ) + ] + ), + [13]: RecordType( + tableName: "modelCs", + schema: """ + CREATE TABLE "modelCs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelBID", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] + ), + [14]: RecordType( + tableName: "unsyncedModels", + schema: """ + CREATE TABLE "unsyncedModels" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ) + ] + ) + ] + """# + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func tearDown() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDown() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } } - } - 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: [] + 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: [] + ) ) - ) - """ - } + """ + } - try await userDatabase.userRead { db in - let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 - #expect(metadataCount == 1) - } - try syncEngine.tearDownSyncEngine() - try await self.userDatabase.userRead { db in - let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 - #expect(metadataCount == 0) + try await userDatabase.userRead { db in + let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 + #expect(metadataCount == 1) + } + try syncEngine.tearDownSyncEngine() + try await self.userDatabase.userRead { db in + let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 + #expect(metadataCount == 0) + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func tearDownAndReSetUp() async throws { - try syncEngine.tearDownSyncEngine() - try syncEngine.setUpSyncEngine() - try await syncEngine.start() + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDownAndReSetUp() async throws { + try syncEngine.tearDownSyncEngine() + try syncEngine.setUpSyncEngine() + try await syncEngine.start() - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } } - } - 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: [] + 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 metadata = + try await userDatabase.userRead { db in + try RemindersList.metadata(for: 1).fetchOne(db) + } + #expect(metadata != nil) } - let metadata = - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1).fetchOne(db) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addAndRemoveFunctions() async throws { + let query = #sql( + """ + SELECT name + FROM pragma_function_list + WHERE name LIKE \(bind: String.sqliteDataCloudKitSchemaName + "_%") + ORDER BY name + """, + as: String.self + ) + assertInlineSnapshot( + of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), + as: .customDump + ) { + """ + [ + [0]: "sqlitedata_icloud_datetime", + [1]: "sqlitedata_icloud_diddelete", + [2]: "sqlitedata_icloud_didupdate", + [3]: "sqlitedata_icloud_haspermission", + [4]: "sqlitedata_icloud_syncengineissynchronizingchanges" + ] + """ } - #expect(metadata != nil) - } + try syncEngine.tearDownSyncEngine() - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addAndRemoveFunctions() async throws { - let query = #sql( - """ - SELECT name - FROM pragma_function_list - WHERE name LIKE \(bind: String.sqliteDataCloudKitSchemaName + "_%") - ORDER BY name - """, - as: String.self - ) - assertInlineSnapshot( - of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), - as: .customDump - ) { - """ - [ - [0]: "sqlitedata_icloud_datetime", - [1]: "sqlitedata_icloud_diddelete", - [2]: "sqlitedata_icloud_didupdate", - [3]: "sqlitedata_icloud_haspermission", - [4]: "sqlitedata_icloud_syncengineissynchronizingchanges" - ] - """ + assertInlineSnapshot( + of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), + as: .customDump + ) { + """ + [] + """ + } } - try syncEngine.tearDownSyncEngine() - assertInlineSnapshot( - of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), - as: .customDump - ) { - """ - [] - """ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func migration() async throws { + // TODO: how to test what happens after a migration? need to assert that zones are fetched. } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func migration() async throws { - // TODO: how to test what happens after a migration? need to assert that zones are fetched. - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertUpdateDelete() async throws { + try await userDatabase.userWrite { db in + try RemindersList + .insert { RemindersList(id: 1, title: "Personal") } + .execute(db) + } + 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: [] + ) + ) + """ + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func insertUpdateDelete() async throws { - try await userDatabase.userWrite { db in - try RemindersList - .insert { RemindersList(id: 1, title: "Personal") } - .execute(db) - } - 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: [] + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList + .find(1) + .update { $0.title = "Work" } + .execute(db) + } + } + 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: "Work" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } + """ + } - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { try await userDatabase.userWrite { db in try RemindersList .find(1) - .update { $0.title = "Work" } + .delete() .execute(db) } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } - 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: "Work" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordUpdate() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + 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)) + 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( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Work" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } - try await userDatabase.userWrite { db in - try RemindersList - .find(1) - .delete() - .execute(db) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerSendsRecordWithNoChanges() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) + } + } + + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() + 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: "My stuff" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordUpdateWithOldRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + 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" + // 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) + 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: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteServerRecordUpdate() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteServerRecordDeleted() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } } - } - 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: [] + 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)) - 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 record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + try await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]).notify() - let metadata = try #require( - try await userDatabase.userRead { db in + #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) } - ) - #expect(metadata.userModificationDate == serverModificationDate) - 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: "Work" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + #expect(metadata == nil) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteServerSendsRecordWithNoChanges() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") + """ } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func cascadingDeletionOrder() async throws { try await userDatabase.userWrite { db in - try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) + try db.seed { + Tag(title: "fun") + Tag(title: "weekend") + } } - } + for _ in 1...100 { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersListPrivate(id: 1, position: 1, remindersListID: 1) + Reminder(id: 1, title: "", remindersListID: 1) + Reminder(id: 2, title: "", remindersListID: 1) + Reminder(id: 3, title: "", remindersListID: 1) + Reminder(id: 4, title: "", remindersListID: 1) + ReminderTag(id: 1, reminderID: 1, tagID: "fun") + ReminderTag(id: 2, reminderID: 2, tagID: "fun") + ReminderTag(id: 3, reminderID: 3, tagID: "fun") + ReminderTag(id: 4, reminderID: 4, tagID: "fun") + ReminderTag(id: 5, reminderID: 1, tagID: "weekend") + ReminderTag(id: 6, reminderID: 2, tagID: "weekend") + ReminderTag(id: 7, reminderID: 3, tagID: "weekend") + ReminderTag(id: 8, reminderID: 4, tagID: "weekend") + } + } - let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) - try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() - 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: "My stuff" + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(fun:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "fun" + ), + [1]: CKRecord( + recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "weekend" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ + ) + """ + } + } } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteServerRecordUpdateWithOldRecord() async throws { + @Test func generatedColumns() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: 1, title: "Personal") + ModelA(id: 1, count: 42, isEven: true) } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -817,12 +1030,12 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", parent: nil, share: nil, - id: 1, - title: "Personal" + count: 42, + id: 1 ) ] ), @@ -834,65 +1047,10 @@ extension BaseCloudKitTests { """ } - 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" - // NB: Manually setting '_recordChangeTag' simulates another device saving a record. - record._recordChangeTag = UUID().uuidString + let record = try syncEngine.private.database.record(for: ModelA.recordID(for: 1)) + record.encryptedValues["isEven"] = false 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) - 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: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteServerRecordDeleted() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -900,12 +1058,13 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", parent: nil, share: nil, + count: 42, id: 1, - title: "Personal" + isEven: 0 ) ] ), @@ -917,169 +1076,12 @@ extension BaseCloudKitTests { """ } - 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) - } - #expect(metadata == nil) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func cascadingDeletionOrder() async throws { - try await userDatabase.userWrite { db in - try db.seed { - Tag(title: "fun") - Tag(title: "weekend") - } - } - for _ in 1...100 { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersListPrivate(id: 1, position: 1, remindersListID: 1) - Reminder(id: 1, title: "", remindersListID: 1) - Reminder(id: 2, title: "", remindersListID: 1) - Reminder(id: 3, title: "", remindersListID: 1) - Reminder(id: 4, title: "", remindersListID: 1) - ReminderTag(id: 1, reminderID: 1, tagID: "fun") - ReminderTag(id: 2, reminderID: 2, tagID: "fun") - ReminderTag(id: 3, reminderID: 3, tagID: "fun") - ReminderTag(id: 4, reminderID: 4, tagID: "fun") - ReminderTag(id: 5, reminderID: 1, tagID: "weekend") - ReminderTag(id: 6, reminderID: 2, tagID: "weekend") - ReminderTag(id: 7, reminderID: 3, tagID: "weekend") - ReminderTag(id: 8, reminderID: 4, tagID: "weekend") - } - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(fun:tags/zone/__defaultOwner__), - recordType: "tags", - parent: nil, - share: nil, - title: "fun" - ), - [1]: CKRecord( - recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), - recordType: "tags", - parent: nil, - share: nil, - title: "weekend" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func generatedColumns() async throws { - try await userDatabase.userWrite { db in - try db.seed { - ModelA(id: 1, count: 42, isEven: true) + try await userDatabase.read { db in + let modelA = try #require(try ModelA.find(1).fetchOne(db)) + #expect(modelA.isEven == true) } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), - recordType: "modelAs", - parent: nil, - share: nil, - count: 42, - id: 1 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - let record = try syncEngine.private.database.record(for: ModelA.recordID(for: 1)) - record.encryptedValues["isEven"] = false - try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), - recordType: "modelAs", - parent: nil, - share: nil, - count: 42, - id: 1, - isEven: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await userDatabase.read { db in - let modelA = try #require(try ModelA.find(1).fetchOne(db)) - #expect(modelA.isEven == true) - } + // TODO: Test what happens when we delete locally and then an edit comes in from the server } - - // TODO: Test what happens when we delete locally and then an edit comes in from the server -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 64970810..a39bc452 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -1,76 +1,34 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SQLiteDataTestSupport -import SnapshotTestingCustomDump -import Testing - -extension BaseCloudKitTests { - @MainActor - @Suite - final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func saveExtraFieldsToSyncMetadata() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let reminderRecord = try syncEngine.private.database - .record(for: Reminder.recordID(for: 1)) - reminderRecord.setValue("Hello world! 🌎🌎🌎", forKey: "newField", at: now) - - try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - - do { - let lastKnownServerRecords = try await syncEngine.metadatabase.read { db in - try SyncMetadata - .order(by: \.recordName) - .select(\._lastKnownServerRecordAllFields) - .fetchAll(db) - } - assertInlineSnapshot(of: lastKnownServerRecords, as: .customDump) { - """ - [ - [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, - newField: "Hello world! 🌎🌎🌎", - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - """ - } - } - - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SQLiteDataTestSupport + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + @Suite + final class FetchRecordZoneChangeTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveExtraFieldsToSyncMetadata() async throws { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue("Hello world! 🌎🌎🌎", forKey: "newField", at: now) + + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + do { let lastKnownServerRecords = try await syncEngine.metadatabase.read { db in try SyncMetadata @@ -87,7 +45,7 @@ extension BaseCloudKitTests { parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, - isCompleted: 1, + isCompleted: 0, newField: "Hello world! 🌎🌎🌎", remindersListID: 1, title: "Get milk" @@ -104,442 +62,486 @@ extension BaseCloudKitTests { """ } } - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteChangeParentRelationship() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - 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.isCompleted.toggle() }.execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + do { + let lastKnownServerRecords = try await syncEngine.metadatabase.read { db in + try SyncMetadata + .order(by: \.recordName) + .select(\._lastKnownServerRecordAllFields) + .fetchAll(db) + } + assertInlineSnapshot(of: lastKnownServerRecords, as: .customDump) { + """ + [ + [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: 1, + newField: "Hello world! 🌎🌎🌎", + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + """ + } + } } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - let reminderRecord = try syncEngine.private.database - .record(for: Reminder.recordID(for: 1)) - reminderRecord.setValue("2", forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 2), - action: .none - ) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteChangeParentRelationship() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - } + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue("2", forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 2), + action: .none + ) - 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 syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + } - 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)) - } + 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.userWrite { db in - try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) - } + 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 syncEngine.processPendingRecordZoneChanges(scope: .private) - - 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.userWrite { db in + try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) + } - 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 - ) - ) - } - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - @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( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "1", - title: "Personal" + 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, + isCompleted: true, + title: "Get milk", + remindersListID: 2 ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) - ) - """ + } } - try await userDatabase.read { db in - let metadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) + @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) ) - #expect(metadata.recordName == RemindersList.recordName(for: 1)) - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) - } + remindersListRecord.setValue("1", forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - try await userDatabase.userWrite { db in - try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) - } + try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]).notify() - 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: [] + ) + ) + """ + } - 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: "My stuff" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + 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")) + } - try await userDatabase.read { db in - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "My stuff")) - } - } + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func receiveNewRecordFromCloudKit_ChildBeforeParent() 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) - - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1) - ) - reminderRecord.setValue("1", forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue("1", forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 1), - action: .none - ) - - let remindersListModification = try syncEngine.modifyRecords( - scope: .private, - 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 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: "My stuff" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } + + try await userDatabase.read { db in + let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) + #expect(remindersList == RemindersList(id: 1, title: "My stuff")) + } } - await remindersListModification.notify() + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveNewRecordFromCloudKit_ChildBeforeParent() 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 userDatabase.read { db in - let reminderMetadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue("1", forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue("1", forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none ) - #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) + let remindersListModification = try syncEngine.modifyRecords( + scope: .private, + saving: [remindersListRecord] ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) + 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: [] + ) + ) + """ + } - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) + await remindersListModification.notify() - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) - } + 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)) - 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) + 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 syncEngine.processPendingRecordZoneChanges(scope: .private) - } + 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) + } - 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: "Buy 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 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: "Buy 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 #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 1)) + 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, *) - @Test func deleteMultipleRecords() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 3, title: "Get milk", remindersListID: 1) - RemindersList(id: 2, title: "Business") - Reminder(id: 4, title: "Call accountant", remindersListID: 2) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteMultipleRecords() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 3, title: "Get milk", remindersListID: 1) + RemindersList(id: 2, title: "Business") + Reminder(id: 4, title: "Call accountant", remindersListID: 2) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await syncEngine.modifyRecords( - scope: .private, - deleting: [ - RemindersList.recordID(for: 1), - RemindersList.recordID(for: 2), - Reminder.recordID(for: 3), - Reminder.recordID(for: 4), - ] - ) - .notify() - - try await userDatabase.read { db in - try #expect(Reminder.all.fetchCount(db) == 0) - try #expect(RemindersList.all.fetchCount(db) == 0) - } - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func receiveRecord_SingleFieldPrimaryKey() async throws { - let tagRecord = CKRecord(recordType: "tags", recordID: Tag.recordID(for: "weekend")) - tagRecord.encryptedValues["title"] = "weekend" - try await syncEngine.modifyRecords(scope: .private, saving: [tagRecord]).notify() + try await syncEngine.modifyRecords( + scope: .private, + deleting: [ + RemindersList.recordID(for: 1), + RemindersList.recordID(for: 2), + Reminder.recordID(for: 3), + Reminder.recordID(for: 4), + ] + ) + .notify() - try await userDatabase.read { db in - try #expect(Tag.all.fetchAll(db) == [Tag(title: "weekend")]) + try await userDatabase.read { db in + try #expect(Reminder.all.fetchCount(db) == 0) + try #expect(RemindersList.all.fetchCount(db) == 0) + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func renamePrimaryKey() async throws { - try await userDatabase.userWrite { db in - try db.seed { - Tag(title: "weekend") - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - ReminderTag(id: 1, reminderID: 1, tagID: "weekend") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveRecord_SingleFieldPrimaryKey() async throws { + let tagRecord = CKRecord(recordType: "tags", recordID: Tag.recordID(for: "weekend")) + tagRecord.encryptedValues["title"] = "weekend" + try await syncEngine.modifyRecords(scope: .private, saving: [tagRecord]).notify() + + try await userDatabase.read { db in + try #expect(Tag.all.fetchAll(db) == [Tag(title: "weekend")]) } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func renamePrimaryKey() async throws { try await userDatabase.userWrite { db in - try Tag.find("weekend").update { $0.title = "optional" }.execute(db) + try db.seed { + Tag(title: "weekend") + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + ReminderTag(id: 1, reminderID: 1, tagID: "weekend") + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertQuery(SyncMetadata.select(\.recordName), database: userDatabase.database) { - """ - ┌────────────────────┐ - │ "1:remindersLists" │ - │ "1:reminders" │ - │ "1:reminderTags" │ - │ "optional:tags" │ - └────────────────────┘ - """ - } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), - recordType: "reminderTags", - parent: nil, - share: nil, - id: 1, - reminderID: 1, - tagID: "optional" - ), - [1]: 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" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ), - [3]: CKRecord( - recordID: CKRecord.ID(optional:tags/zone/__defaultOwner__), - recordType: "tags", - parent: nil, - share: nil, - title: "optional" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try Tag.find("weekend").update { $0.title = "optional" }.execute(db) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(SyncMetadata.select(\.recordName), database: userDatabase.database) { + """ + ┌────────────────────┐ + │ "1:remindersLists" │ + │ "1:reminders" │ + │ "1:reminderTags" │ + │ "optional:tags" │ + └────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), + recordType: "reminderTags", + parent: nil, + share: nil, + id: 1, + reminderID: 1, + tagID: "optional" + ), + [1]: 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" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ), + [3]: CKRecord( + recordID: CKRecord.ID(optional:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "optional" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift index 40b05ff7..d73cb343 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchedDatabaseChangesTests.swift @@ -1,150 +1,152 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - @Suite - final class FetchedDatabaseChangesTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteSyncEngineZone() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - Reminder(id: 2, title: "Call accountant", remindersListID: 2) - RemindersListPrivate(id: 1, remindersListID: 1) - RemindersListPrivate(id: 2, remindersListID: 2) - UnsyncedModel(id: 1) - UnsyncedModel(id: 2) + extension BaseCloudKitTests { + @MainActor + @Suite + final class FetchedDatabaseChangesTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteSyncEngineZone() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + Reminder(id: 2, title: "Call accountant", remindersListID: 2) + RemindersListPrivate(id: 1, remindersListID: 1) + RemindersListPrivate(id: 2, remindersListID: 2) + UnsyncedModel(id: 1) + UnsyncedModel(id: 2) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await syncEngine.modifyRecordZones( - scope: .private, - deleting: [syncEngine.defaultZone.zoneID] - ).notify() - try await syncEngine.processPendingDatabaseChanges(scope: .private) + try await syncEngine.modifyRecordZones( + scope: .private, + deleting: [syncEngine.defaultZone.zoneID] + ).notify() + try await syncEngine.processPendingDatabaseChanges(scope: .private) - try await userDatabase.read { db in - try #expect(Reminder.all.fetchAll(db) == []) - try #expect(RemindersList.all.fetchAll(db) == []) - try #expect(RemindersListPrivate.all.fetchAll(db) == []) - try #expect( - UnsyncedModel.all.fetchAll(db) == [UnsyncedModel(id: 1), UnsyncedModel(id: 2)] - ) + try await userDatabase.read { db in + try #expect(Reminder.all.fetchAll(db) == []) + try #expect(RemindersList.all.fetchAll(db) == []) + try #expect(RemindersListPrivate.all.fetchAll(db) == []) + try #expect( + UnsyncedModel.all.fetchAll(db) == [UnsyncedModel(id: 1), UnsyncedModel(id: 2)] + ) + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteSyncEngineZone_EncryptedDataReset() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - Reminder(id: 2, title: "Call accountant", remindersListID: 2) - RemindersListPrivate(id: 1, remindersListID: 1) - RemindersListPrivate(id: 2, remindersListID: 2) - UnsyncedModel(id: 1) - UnsyncedModel(id: 2) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteSyncEngineZone_EncryptedDataReset() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + Reminder(id: 2, title: "Call accountant", remindersListID: 2) + RemindersListPrivate(id: 1, remindersListID: 1) + RemindersListPrivate(id: 2, remindersListID: 2) + UnsyncedModel(id: 1) + UnsyncedModel(id: 2) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await syncEngine - .handleEvent( - SyncEngine.Event.fetchedDatabaseChanges( - modifications: [], - deletions: [(syncEngine.defaultZone.zoneID, .encryptedDataReset)] - ), - syncEngine: syncEngine.private - ) - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await syncEngine + .handleEvent( + SyncEngine.Event.fetchedDatabaseChanges( + modifications: [], + deletions: [(syncEngine.defaultZone.zoneID, .encryptedDataReset)] + ), + syncEngine: syncEngine.private + ) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.read { db in - try #expect(Reminder.count().fetchOne(db) == 2) - try #expect(RemindersList.count().fetchOne(db) == 2) - try #expect(RemindersListPrivate.count().fetchOne(db) == 2) - try #expect(UnsyncedModel.count().fetchOne(db) == 2) - } + try await userDatabase.read { db in + try #expect(Reminder.count().fetchOne(db) == 2) + try #expect(RemindersList.count().fetchOne(db) == 2) + try #expect(RemindersListPrivate.count().fetchOne(db) == 2) + try #expect(UnsyncedModel.count().fetchOne(db) == 2) + } - 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(2:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 2, - isCompleted: 0, - remindersListID: 2, - title: "Call accountant" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), - recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - position: 0, - remindersListID: 1 - ), - [3]: CKRecord( - recordID: CKRecord.ID(2:remindersListPrivates/zone/__defaultOwner__), - recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 2, - position: 0, - remindersListID: 2 - ), - [4]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ), - [5]: CKRecord( - recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 2, - title: "Business" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + 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(2:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 2, + isCompleted: 0, + remindersListID: 2, + title: "Call accountant" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), + recordType: "remindersListPrivates", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + position: 0, + remindersListID: 1 + ), + [3]: CKRecord( + recordID: CKRecord.ID(2:remindersListPrivates/zone/__defaultOwner__), + recordType: "remindersListPrivates", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 2, + position: 0, + remindersListID: 2 + ), + [4]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ), + [5]: CKRecord( + recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 2, + title: "Business" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index 47915f04..dcb1e869 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -1,823 +1,827 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import SQLiteData -import SnapshotTestingCustomDump -import Testing - -extension BaseCloudKitTests { - @MainActor - final class ForeignKeyConstraintTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func receiveChildBeforeParent() 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) - - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - record: remindersListRecord, - action: .none - ) - - let remindersListModification = try syncEngine.modifyRecords( - scope: .private, - 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: [] - ) +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class ForeignKeyConstraintTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveChildBeforeParent() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) ) - """ - } - - 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() + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) - try await userDatabase.read { db in - let reminderMetadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none ) - #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) + let remindersListModification = try syncEngine.modifyRecords( + scope: .private, + saving: [remindersListRecord] ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) + 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: [] + ) + ) + """ + } - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) + 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) + } - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) - } + await remindersListModification.notify() - 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 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 syncEngine.processPendingRecordZoneChanges(scope: .private) + 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) + } + + 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: "Buy 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 #require(try Reminder.find(1).fetchOne(db)) + #expect(reminder == Reminder.init(id: 1, title: "Buy milk", remindersListID: 1)) + } } - 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: "Buy milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) + /* + * 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. + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteCreatesRecordABC_localReceivesAC_remoteDeletesBC() async throws { + let modelARecord = CKRecord(recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1)) + let modelBRecord = CKRecord(recordType: ModelB.tableName, recordID: ModelB.recordID(for: 1)) + modelBRecord.setValue(1, forKey: "modelAID", at: now) + modelBRecord.parent = CKRecord.Reference(record: modelARecord, action: .none) + let modelCRecord = CKRecord(recordType: ModelC.tableName, recordID: ModelC.recordID(for: 1)) + modelCRecord.setValue(1, forKey: "modelBID", at: now) + modelCRecord.parent = CKRecord.Reference(record: modelBRecord, action: .none) + + try await syncEngine.modifyRecords(scope: .private, saving: [modelARecord]).notify() + _ = 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)) ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) + } + + try await syncEngine.modifyRecords( + scope: .private, + deleting: [modelCRecord.recordID, modelBRecord.recordID] ) - """ - } + .notify() - 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)) + try await userDatabase.read { db in + try #expect( + UnsyncedRecordID.all.fetchAll(db) == [] + ) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), + recordType: "modelAs", + parent: nil, + share: nil + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } - } - /* - * 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. - */ - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func remoteCreatesRecordABC_localReceivesAC_remoteDeletesBC() async throws { - let modelARecord = CKRecord(recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1)) - let modelBRecord = CKRecord(recordType: ModelB.tableName, recordID: ModelB.recordID(for: 1)) - modelBRecord.setValue(1, forKey: "modelAID", at: now) - modelBRecord.parent = CKRecord.Reference(record: modelARecord, action: .none) - let modelCRecord = CKRecord(recordType: ModelC.tableName, recordID: ModelC.recordID(for: 1)) - modelCRecord.setValue(1, forKey: "modelBID", at: now) - modelCRecord.parent = CKRecord.Reference(record: modelBRecord, action: .none) - - try await syncEngine.modifyRecords(scope: .private, saving: [modelARecord]).notify() - _ = 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))] + // * 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 receiveChildRecordBeforeParent_ReceiveChildAndParentRecord() 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, - deleting: [modelCRecord.recordID, modelBRecord.recordID] - ) - .notify() + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) - try await userDatabase.read { db in - try #expect( - UnsyncedRecordID.all.fetchAll(db) == [] + _ = try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + let freshReminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) ) - } + let freshRemindersListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + try await syncEngine.modifyRecords( + scope: .private, + saving: [freshReminderRecord, freshRemindersListRecord] + ) + .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: [] + ) + ) + """ + } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), - recordType: "modelAs", - parent: nil, - share: nil - ) + try await userDatabase.read { db in + try #expect( + RemindersList.all.fetchAll(db) == [ + RemindersList(id: 1, title: "Personal") ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) - ) - """ - } - } - - // * 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 receiveChildRecordBeforeParent_ReceiveChildAndParentRecord() 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) - - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - record: remindersListRecord, - action: .none - ) - - _ = try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) - try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - let freshReminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: 1) - ) - let freshRemindersListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: 1) - ) - try await syncEngine.modifyRecords( - scope: .private, - saving: [freshReminderRecord, freshRemindersListRecord] - ) - .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" - ) + try #expect( + Reminder.all.fetchAll(db) == [ + Reminder(id: 1, title: "Get milk", remindersListID: 1) ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) - ) - """ + } } - try await userDatabase.read { db in - try #expect( - RemindersList.all.fetchAll(db) == [ - RemindersList(id: 1, title: "Personal") - ] + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveChild_Relaunch_ReceiveParent() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) ) - try #expect( - Reminder.all.fetchAll(db) == [ - Reminder(id: 1, title: "Get milk", remindersListID: 1) - ] + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none ) - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func receiveChild_Relaunch_ReceiveParent() 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) - - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 1), - action: .none - ) - - _ = try { try syncEngine.modifyRecords(scope: .private, 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 { try syncEngine.modifyRecords(scope: .private, 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 reminder = try Reminder.find(1).fetchOne(db) - #expect(reminder == nil) - } + 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, - tables: syncEngine.tables, - privateTables: syncEngine.privateTables - ) - - await relaunchedSyncEngine - .handleEvent( - .fetchedRecordZoneChanges(modifications: [remindersListRecord], deletions: []), - syncEngine: relaunchedSyncEngine.private + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables, + privateTables: syncEngine.privateTables ) - 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)) + await relaunchedSyncEngine + .handleEvent( + .fetchedRecordZoneChanges(modifications: [remindersListRecord], deletions: []), + syncEngine: relaunchedSyncEngine.private + ) - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) - ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) + 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 reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 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) + let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) + #expect(remindersList == RemindersList(id: 1, title: "Personal")) } - try await relaunchedSyncEngine.processPendingRecordZoneChanges(scope: .private) - } + 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) + } - 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: "Buy 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 relaunchedSyncEngine.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: "Buy 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 #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder.init(id: 1, title: "Buy milk", remindersListID: 1)) + 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. - // * Remote syncs child to local. - // * Remote syncs parent to local. - // => Parent and child records are synchronized. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test - func changeParentRelationshipToUnknownRecord() async throws { - let personalListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1) - ) - personalListRecord.setValue(1, forKey: "id", at: now) - personalListRecord.setValue("Personal", forKey: "title", at: now) - - let businessListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 2) - ) - businessListRecord.setValue(2, forKey: "id", at: now) - businessListRecord.setValue("Business", forKey: "title", at: now) - - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - record: personalListRecord, - action: .none - ) - - try await syncEngine.modifyRecords( - scope: .private, - 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: [] - ) + // * Remote moves child to a parent the local client does not know about. + // * Remote syncs child to local. + // * Remote syncs parent to local. + // => Parent and child records are synchronized. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test + func changeParentRelationshipToUnknownRecord() async throws { + let personalListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) ) - """ - } + personalListRecord.setValue(1, forKey: "id", at: now) + personalListRecord.setValue("Personal", forKey: "title", at: now) - let modifications = try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: 1) + let businessListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 2) ) - reminderRecord.setValue(2, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference(record: businessListRecord, action: .none) + businessListRecord.setValue(2, forKey: "id", at: now) + businessListRecord.setValue("Business", forKey: "title", at: now) - let modifications = try syncEngine.modifyRecords( - scope: .private, - saving: [businessListRecord] + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + record: personalListRecord, + action: .none ) - try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - return modifications - } - await modifications.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(2:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - remindersListID: 2, - title: "Get milk" - ), - [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: "Business" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await syncEngine.modifyRecords( + scope: .private, + 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: [] + ) ) - ) - """ - } + """ + } - 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 modifications = try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: businessListRecord, action: .none) - let reminderMetadata = try #require( - try Reminder.metadata(for: 1) - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == "2:remindersLists") - } - } + let modifications = try syncEngine.modifyRecords( + scope: .private, + saving: [businessListRecord] + ) + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + return modifications + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func changeParentRelationship_RemotelyThenLocally() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - RemindersList(id: 3, title: "Secret") - Reminder(id: 1, title: "Get milk", remindersListID: 1) + await modifications.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(2:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + remindersListID: 2, + title: "Get milk" + ), + [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: "Business" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + 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") } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let modifications = try withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - let reminderRecord = try syncEngine.private.database - .record(for: Reminder.recordID(for: 1)) - reminderRecord.setValue(2, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 2), - action: .none - ) - return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) } - try await withDependencies { - $0.datetime.now.addTimeInterval(2) - } operation: { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func changeParentRelationship_RemotelyThenLocally() async throws { try await userDatabase.userWrite { db in - try Reminder.find(1) - .update { - $0.title = "Buy milk" - $0.remindersListID = 3 - } - .execute(db) + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + RemindersList(id: 3, title: "Secret") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modifications.notify() + let modifications = try withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 2), + action: .none + ) + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } - 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 withDependencies { + $0.datetime.now.addTimeInterval(2) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1) + .update { + $0.title = "Buy milk" + $0.remindersListID = 3 + } + .execute(db) + } + } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - 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(3:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 3, - title: "Buy milk" - ) - """ - } + 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 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) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test - func changeParentRelationship_RemoteFirstEdited_LocalSecondEdited_SendBatch_ReceiveCloudKit() - async throws - { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - RemindersList(id: 3, title: "Secret") - Reminder(id: 1, title: "Get milk", remindersListID: 1) + 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(3:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 3, + title: "Buy milk" + ) + """ + } + + 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) - - let modifications = try withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - let reminderRecord = try syncEngine.private.database - .record(for: Reminder.recordID(for: 1)) - reminderRecord.setValue(2, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 2), - action: .none - ) - return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) } - try await withDependencies { - $0.datetime.now.addTimeInterval(2) - } operation: { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test + func changeParentRelationship_RemoteFirstEdited_LocalSecondEdited_SendBatch_ReceiveCloudKit() + async throws + { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.remindersListID = 3 }.execute(db) + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + RemindersList(id: 3, title: "Secret") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + let modifications = try withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + reminderRecord.setValue(2, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 2), + action: .none + ) + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } - 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)) - } + try await withDependencies { + $0.datetime.now.addTimeInterval(2) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.remindersListID = 3 }.execute(db) + } + } - await modifications.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(3:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 3, - title: "Get milk" - ) - """ - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - 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(3:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 3, - 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: 3)) + let reminder = try #require(try Reminder.find(1).fetchOne(db)) + #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 3)) + } - 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() + + 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(3:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 3, + title: "Get milk" + ) + """ + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + 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(3:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 3, + title: "Get milk" + ) + """ + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func cascadingDeletes() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - RemindersList(id: 2, title: "Work") - Reminder(id: 2, title: "Call accountant", remindersListID: 2) - RemindersList(id: 3, title: "Secret") - Reminder(id: 3, title: "Schedule secret meeting", remindersListID: 3) + 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)) } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func cascadingDeletes() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + RemindersList(id: 2, title: "Work") + Reminder(id: 2, title: "Call accountant", remindersListID: 2) + RemindersList(id: 3, title: "Secret") + Reminder(id: 3, title: "Schedule secret meeting", remindersListID: 3) + } + } - try await userDatabase.userWrite { db in - try RemindersList.where { $0.id <= 2 }.delete().execute(db) - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(3:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 3, - isCompleted: 0, - remindersListID: 3, - title: "Schedule secret meeting" - ), - [1]: CKRecord( - recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 3, - title: "Secret" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await userDatabase.userWrite { db in + try RemindersList.where { $0.id <= 2 }.delete().execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(3:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 3, + isCompleted: 0, + remindersListID: 3, + title: "Schedule secret meeting" + ), + [1]: CKRecord( + recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 3, + title: "Secret" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 6409914e..37cb7c3d 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1,705 +1,708 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTestingCustomDump -import Testing - -extension BaseCloudKitTests { - @MainActor - @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable + { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } } - } - 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, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - let userModificationDate = now.addingTimeInterval(60) - record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() + """ + } - try await withDependencies { - $0.datetime.now = now.addingTimeInterval(30) - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + let userModificationDate = now.addingTimeInterval(60) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.datetime.now = now.addingTimeInterval(30) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + } } - } - 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, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } + """ + } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modificationCallback.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, - id🗓️: 0, - isCompleted: 1, - isCompleted🗓️: 30, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modificationCallback.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, + id🗓️: 0, + isCompleted: 1, + isCompleted🗓️: 30, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordUpdatedBeforeClientRecord() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + """ } } - 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, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - let userModificationDate = now.addingTimeInterval(30) - record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - try await withDependencies { - $0.datetime.now = now.addingTimeInterval(60) - } operation: { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordUpdatedBeforeClientRecord() async throws { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } } - } - 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, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 30, - 🗓️: 30 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } + """ + } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modificationCallback.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, - id🗓️: 0, - isCompleted: 1, - isCompleted🗓️: 60, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 30, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + let userModificationDate = now.addingTimeInterval(30) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.datetime.now = now.addingTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + } + } + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 30, + 🗓️: 30 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } - } + """ + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverAndClientEditDifferentFields() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modificationCallback.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, + id🗓️: 0, + isCompleted: 1, + isCompleted🗓️: 60, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 30, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - let userModificationDate = now.addingTimeInterval(30) - record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - try await withDependencies { - $0.datetime.now = now.addingTimeInterval(60) - } operation: { + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverAndClientEditDifferentFields() async throws { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } } - } - await modificationCallback.notify() - 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, - id🗓️: 0, - isCompleted: 1, - isCompleted🗓️: 60, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 30, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + let userModificationDate = now.addingTimeInterval(30) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.datetime.now = now.addingTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + } + } + await modificationCallback.notify() + 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, + id🗓️: 0, + isCompleted: 1, + isCompleted🗓️: 60, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 30, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordEditedAfterClientButProcessedBeforeClient() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + """ } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - let userModificationDate = now.addingTimeInterval(60) - record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - try await withDependencies { - $0.datetime.now = now.addingTimeInterval(30) - } operation: { + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordEditedAfterClientButProcessedBeforeClient() async throws { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } } - } - await modificationCallback.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await userDatabase.userWrite { db in - try #expect( - Reminder.find(1).fetchOne(db) - == Reminder(id: 1, title: "Get milk", remindersListID: 1) - ) - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + let userModificationDate = now.addingTimeInterval(60) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.datetime.now = now.addingTimeInterval(30) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + } + } + await modificationCallback.notify() + 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, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await userDatabase.userWrite { db in + try #expect( + Reminder.find(1).fetchOne(db) + == Reminder(id: 1, title: "Get milk", remindersListID: 1) ) - ) - """ - } - } + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordEditedAndProcessedBeforeClient() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Buy milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - let userModificationDate = now.addingTimeInterval(30) - record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - try await withDependencies { - $0.datetime.now = now.addingTimeInterval(60) - } operation: { + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordEditedAndProcessedBeforeClient() async throws { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } } - } - await modificationCallback.notify() - 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, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Get milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + let userModificationDate = now.addingTimeInterval(30) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.datetime.now = now.addingTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + } + } + await modificationCallback.notify() + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordEditedBeforeClientButProcessedAfterClient() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordEditedBeforeClientButProcessedAfterClient() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "") + Reminder(id: 1, title: "", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + let userModificationDate = now.addingTimeInterval(30) + record.setValue("Buy milk", forKey: "title", at: userModificationDate) + let modificationCallback = try { + try syncEngine.modifyRecords(scope: .private, saving: [record]) + }() + + try await withDependencies { + $0.datetime.now = now.addingTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modificationCallback.notify() + 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, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + remindersListID: 1, + remindersListID🗓️: 0, + title: "Get milk", + title🗓️: 60, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - let userModificationDate = now.addingTimeInterval(30) - record.setValue("Buy milk", forKey: "title", at: userModificationDate) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - try await withDependencies { - $0.datetime.now = now.addingTimeInterval(60) - } operation: { + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func mergeWithNullableFields() async throws { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, remindersListID: 1) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modificationCallback.notify() - 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, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Get milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + reminderRecord.setValue( + now.addingTimeInterval(30), + forKey: "dueDate", + at: now.addingTimeInterval(1) + ) + let modificationsFinished = try syncEngine.modifyRecords( + scope: .private, + saving: [reminderRecord] ) - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func mergeWithNullableFields() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, remindersListID: 1) + try withDependencies { + $0.datetime.now.addTimeInterval(2) + } operation: { + try userDatabase.userWrite { db in + try Reminder.find(1).update { $0.priority = 3 }.execute(db) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: 1) - ) - reminderRecord.setValue( - now.addingTimeInterval(30), - forKey: "dueDate", - at: now.addingTimeInterval(1) - ) - let modificationsFinished = try syncEngine.modifyRecords( - scope: .private, - saving: [reminderRecord] - ) - - try withDependencies { - $0.datetime.now.addTimeInterval(2) - } operation: { - try userDatabase.userWrite { db in - try Reminder.find(1).update { $0.priority = 3 }.execute(db) + + await modificationsFinished.notify() + 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, + dueDate: Date(1970-01-01T00:00:30.000Z), + dueDate🗓️: 1, + id: 1, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + priority: 3, + priority🗓️: 2, + remindersListID: 1, + remindersListID🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 2 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "Personal", + title🗓️: 0, + 🗓️: 0 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ } - } - await modificationsFinished.notify() - 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, - dueDate: Date(1970-01-01T00:00:30.000Z), - dueDate🗓️: 1, + try await userDatabase.read { db in + let reminder = try #require(try Reminder.find(1).fetchOne(db)) + #expect( + reminder + == Reminder( id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, + dueDate: Date(timeIntervalSince1970: 30), priority: 3, - priority🗓️: 2, - remindersListID: 1, - remindersListID🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 2 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "Personal", - title🗓️: 0, - 🗓️: 0 + remindersListID: 1 ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) - ) - """ - } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect( - reminder - == Reminder( - id: 1, - dueDate: Date(timeIntervalSince1970: 30), - priority: 3, - remindersListID: 1 - ) - ) + } } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift index 85cfaa80..67d4d1e3 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift @@ -1,255 +1,229 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func parentRecordName() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Work") - Reminder(id: 1, title: "Groceries", remindersListID: 1) + extension BaseCloudKitTests { + @MainActor + final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func parentRecordName() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Work") + Reminder(id: 1, title: "Groceries", remindersListID: 1) + } } - } - - 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) + 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: [] ) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 2)) - } - }() - } - - 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(2:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 2, - 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: [] ) - ) - """ - } - } + """ + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func noParentRecordForRecordsWithMultipleForeignKeys() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Groceries", remindersListID: 1) - Tag(title: "weekend") - ReminderTag(id: 1, reminderID: 1, tagID: "weekend") + 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 await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), - recordType: "reminderTags", - parent: nil, - share: nil, - id: 1, - reminderID: 1, - tagID: "weekend" - ), - [1]: 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" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ), - [3]: CKRecord( - recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), - recordType: "tags", - parent: nil, - share: nil, - title: "weekend" + 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) ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 2)) + } + }() + } - let parentRecordNames = try await userDatabase.userRead { db in - try SyncMetadata - .where { $0.recordType != Reminder.tableName } - .select(\.parentRecordName) - .fetchAll(db) + 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(2:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 2, + 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: [] + ) + ) + """ + } } - #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) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func noParentRecordForRecordsWithMultipleForeignKeys() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Groceries", remindersListID: 1) + Tag(title: "weekend") + ReminderTag(id: 1, reminderID: 1, tagID: "weekend") + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), + recordType: "reminderTags", + parent: nil, + share: nil, + id: 1, + reminderID: 1, + tagID: "weekend" + ), + [1]: 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" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ), + [3]: CKRecord( + recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "weekend" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } - let reminderMetadata = try await userDatabase.userRead { db in - try SyncMetadata - .where { $0.recordType == Reminder.tableName } - .fetchAll(db) + let parentRecordNames = try await userDatabase.userRead { db in + try SyncMetadata + .where { $0.recordType != Reminder.tableName } + .select(\.parentRecordName) + .fetchAll(db) + } + #expect(parentRecordNames.allSatisfy { $0 == nil }) } - #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) + @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) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.userRead { db in - let reminderMetadata = + let reminderMetadata = try await userDatabase.userRead { db in try SyncMetadata - .where { $0.parentRecordType == RemindersList.tableName } - .fetchAll(db) + .where { $0.recordType == Reminder.tableName } + .fetchAll(db) + } #expect( reminderMetadata.map(\.recordName) == [ Reminder.recordName(for: 2), @@ -258,34 +232,62 @@ extension BaseCloudKitTests { ] ) } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func parentRecordPrimaryKey() 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) + @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), + ] + ) } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func parentRecordPrimaryKey() 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 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), - ] - ) + 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), + ] + ) + } } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index 73750e4c..d0e71c1f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -1,427 +1,429 @@ -import CloudKit -import ConcurrencyExtras -import CustomDump -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTestingCustomDump -import Testing - -extension BaseCloudKitTests { - @MainActor - final class MockCloudDatabaseTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - try await super.init() - let (saveZoneResults, _) = try syncEngine.private.database.modifyRecordZones( - saving: [ - CKRecordZone( - zoneID: CKRecord(recordType: "A\(Int.random(in: 1...999_999_999))").recordID.zoneID - ) - ], - deleting: [] - ) - #expect(saveZoneResults.allSatisfy({ (try? $1.get()) != nil })) - } +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class MockCloudDatabaseTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() async throws { + try await super.init() + let (saveZoneResults, _) = try syncEngine.private.database.modifyRecordZones( + saving: [ + CKRecordZone( + zoneID: CKRecord(recordType: "A\(Int.random(in: 1...999_999_999))").recordID.zoneID + ) + ], + deleting: [] + ) + #expect(saveZoneResults.allSatisfy({ (try? $1.get()) != nil })) + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func fetchRecordInUnknownZone() async throws { - let error = #expect(throws: CKError.self) { - try self.syncEngine.private.database.record( - for: CKRecord.ID( - recordName: "A", - zoneID: CKRecordZone.ID(zoneName: "unknownZone") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func fetchRecordInUnknownZone() async throws { + let error = #expect(throws: CKError.self) { + try self.syncEngine.private.database.record( + for: CKRecord.ID( + recordName: "A", + zoneID: CKRecordZone.ID(zoneName: "unknownZone") + ) ) - ) + } + #expect(error == CKError(.zoneNotFound)) } - #expect(error == CKError(.zoneNotFound)) - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func fetchUnknownRecord() async throws { - let error = #expect(throws: CKError.self) { - try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "A")) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func fetchUnknownRecord() async throws { + let error = #expect(throws: CKError.self) { + try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "A")) + } + #expect(error == CKError(.unknownItem)) } - #expect(error == CKError(.unknownItem)) - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func saveTransaction_ChildBeforeParent() async throws { - let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) - let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) - child.parent = CKRecord.Reference(record: parent, action: .none) - - let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( - saving: [child, parent], - deleting: [] - ) - #expect(saveRecordResults.allSatisfy({ (try? $1.get()) != nil })) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__), - recordType: "A", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(B/_defaultZone/__defaultOwner__), - recordType: "B", - parent: CKReference(recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__)), - share: nil - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveTransaction_ChildBeforeParent() async throws { + let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) + child.parent = CKRecord.Reference(record: parent, action: .none) + + let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( + saving: [child, parent], + deleting: [] ) - """ - } - } + #expect(saveRecordResults.allSatisfy({ (try? $1.get()) != nil })) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func saveTransaction_ChildNoParent() async throws { - let parent = CKRecord(recordType: "Parent", recordID: CKRecord.ID(recordName: "Parent")) - let child = CKRecord(recordType: "Child", recordID: CKRecord.ID(recordName: "Child")) - child.parent = CKRecord.Reference(record: parent, action: .none) - - let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( - saving: [child], - deleting: [] - ) - let error = #expect(throws: CKError.self) { - try saveRecordResults[child.recordID]?.get() - } - #expect(error == CKError(.referenceViolation)) - - try await syncEngine.modifyRecords(scope: .private, saving: [child]).notify() - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__), + recordType: "A", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(B/_defaultZone/__defaultOwner__), + recordType: "B", + parent: CKReference(recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__)), + share: nil + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func saveInUnknownZone() async throws { - let record = CKRecord( - recordType: "Record", - recordID: CKRecord.ID( - recordName: "Record", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) - ) - - let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( - saving: [record], - deleting: [] - ) - let error = #expect(throws: CKError.self) { - try saveRecordResults[record.recordID]?.get() - } - #expect(error == CKError(.zoneNotFound)) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveTransaction_ChildNoParent() async throws { + let parent = CKRecord(recordType: "Parent", recordID: CKRecord.ID(recordName: "Parent")) + let child = CKRecord(recordType: "Child", recordID: CKRecord.ID(recordName: "Child")) + child.parent = CKRecord.Reference(record: parent, action: .none) + + let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( + saving: [child], + deleting: [] ) - """ - } - } + let error = #expect(throws: CKError.self) { + try saveRecordResults[child.recordID]?.get() + } + #expect(error == CKError(.referenceViolation)) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteTransaction_ParentBeforeChild() async throws { - let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) - let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) - child.parent = CKRecord.Reference(record: parent, action: .none) - - let _ = try syncEngine.private.database.modifyRecords(saving: [child, parent]) - let (_, deleteResults) = try syncEngine.private.database.modifyRecords( - deleting: [parent.recordID, child.recordID] - ) - #expect(deleteResults.allSatisfy({ (try? $1.get()) != nil })) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await syncEngine.modifyRecords(scope: .private, saving: [child]).notify() + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteUnknownRecord() async throws { - let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) - - let (_, deleteResults) = try syncEngine.private.database.modifyRecords( - deleting: [record.recordID] - ) - #expect(deleteResults.allSatisfy({ (try? $1.get()) != nil })) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveInUnknownZone() async throws { + let record = CKRecord( + recordType: "Record", + recordID: CKRecord.ID( + recordName: "Record", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) ) - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteRecordInUnknownZone() async throws { - let record = CKRecord( - recordType: "A", - recordID: CKRecord.ID(recordName: "A", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) - ) - - let (_, deleteResults) = try syncEngine.private.database.modifyRecords( - deleting: [record.recordID] - ) - let error = #expect(throws: CKError.self) { - try deleteResults[record.recordID]?.get() - } - #expect(error == CKError(.zoneNotFound)) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + let (saveRecordResults, _) = try syncEngine.private.database.modifyRecords( + saving: [record], + deleting: [] ) - """ - } - } + let error = #expect(throws: CKError.self) { + try saveRecordResults[record.recordID]?.get() + } + #expect(error == CKError(.zoneNotFound)) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteTransaction_DeleteParentButNotChild() async throws { - let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) - let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) - child.parent = CKRecord.Reference(record: parent, action: .none) - - _ = try syncEngine.private.database.modifyRecords(saving: [child, parent]) - let (_, deleteResults) = try syncEngine.private.database.modifyRecords( - deleting: [parent.recordID] - ) - let error = #expect(throws: CKError.self) { - try deleteResults[CKRecord.ID(recordName: "A")]?.get() - } - #expect(error == CKError(.referenceViolation)) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__), - recordType: "A", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(B/_defaultZone/__defaultOwner__), - recordType: "B", - parent: CKReference(recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__)), - share: nil - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteUnknownZone() async throws { - let (_, deleteResults) = try syncEngine.private.database.modifyRecordZones( - saving: [], - deleting: [CKRecordZone.ID(zoneName: "unknownZone")] - ) - let error = #expect(throws: CKError.self) { - try deleteResults[CKRecordZone.ID(zoneName: "unknownZone")]?.get() - } - #expect(error == CKError(.zoneNotFound)) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteTransaction_ParentBeforeChild() async throws { + let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) + child.parent = CKRecord.Reference(record: parent, action: .none) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func accountTemporarilyAvailable() async throws { - container._accountStatus.withValue { $0 = .temporarilyUnavailable } - var error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.modifyRecordZones() - } - #expect(error == CKError(.accountTemporarilyUnavailable)) - error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.modifyRecords() - } - #expect(error == CKError(.accountTemporarilyUnavailable)) - error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) - } - #expect(error == CKError(.accountTemporarilyUnavailable)) - error = await #expect(throws: CKError.self) { - _ = try await self.syncEngine.private.database.records(for: [ - CKRecord.ID(recordName: "test") - ]) - } - #expect(error == CKError(.accountTemporarilyUnavailable)) - } + let _ = try syncEngine.private.database.modifyRecords(saving: [child, parent]) + let (_, deleteResults) = try syncEngine.private.database.modifyRecords( + deleting: [parent.recordID, child.recordID] + ) + #expect(deleteResults.allSatisfy({ (try? $1.get()) != nil })) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func noAccount() async throws { - container._accountStatus.withValue { $0 = .noAccount } - var error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.modifyRecordZones() - } - #expect(error == CKError(.notAuthenticated)) - error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.modifyRecords() - } - #expect(error == CKError(.notAuthenticated)) - error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) - } - #expect(error == CKError(.notAuthenticated)) - error = await #expect(throws: CKError.self) { - _ = try await self.syncEngine.private.database.records(for: [ - CKRecord.ID(recordName: "test") - ]) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } - #expect(error == CKError(.notAuthenticated)) - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func accountNotDetermined() async throws { - container._accountStatus.withValue { $0 = .couldNotDetermine } - var error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.modifyRecordZones() - } - #expect(error == CKError(.notAuthenticated)) - error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.modifyRecords() - } - #expect(error == CKError(.notAuthenticated)) - error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) - } - #expect(error == CKError(.notAuthenticated)) - error = await #expect(throws: CKError.self) { - _ = try await self.syncEngine.private.database.records(for: [ - CKRecord.ID(recordName: "test") - ]) - } - #expect(error == CKError(.notAuthenticated)) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteUnknownRecord() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func restrictedAccount() async throws { - container._accountStatus.withValue { $0 = .restricted } - var error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.modifyRecordZones() - } - #expect(error == CKError(.notAuthenticated)) - error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.modifyRecords() - } - #expect(error == CKError(.notAuthenticated)) - error = #expect(throws: CKError.self) { - _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) - } - #expect(error == CKError(.notAuthenticated)) - error = await #expect(throws: CKError.self) { - _ = try await self.syncEngine.private.database.records(for: [ - CKRecord.ID(recordName: "test") - ]) - } - #expect(error == CKError(.notAuthenticated)) - } + let (_, deleteResults) = try syncEngine.private.database.modifyRecords( + deleting: [record.recordID] + ) + #expect(deleteResults.allSatisfy({ (try? $1.get()) != nil })) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func incorrectlyCreatingNewRecordIdentity() async throws { - let record1 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) - _ = try syncEngine.modifyRecords(scope: .private, saving: [record1]) - let record2 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) - try withKnownIssue { - _ = try syncEngine.modifyRecords(scope: .private, saving: [record2]) - } matching: { issue in - issue.description == """ - Issue recorded: A new identity was created for an existing 'CKRecord' ('1'). Rather than \ - creating 'CKRecord' from scratch for an existing record, use the database to fetch the \ - current record. + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func saveShareWithoutRootRecord() async throws { - let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) - let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) - try withKnownIssue { - _ = try syncEngine.modifyRecords(scope: .private, saving: [share]) - } matching: { issue in - issue.description == """ - Issue recorded: An added share is being saved without its rootRecord being saved in the \ - same operation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRecordInUnknownZone() async throws { + let record = CKRecord( + recordType: "A", + recordID: CKRecord.ID(recordName: "A", zoneID: CKRecordZone.ID(zoneName: "unknownZone")) + ) + + let (_, deleteResults) = try syncEngine.private.database.modifyRecords( + deleting: [record.recordID] + ) + let error = #expect(throws: CKError.self) { + try deleteResults[record.recordID]?.get() + } + #expect(error == CKError(.zoneNotFound)) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func saveShareAndRootThenSaveShareAlone() async throws { - let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) - let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) - _ = try syncEngine.modifyRecords(scope: .private, saving: [share, record]) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteTransaction_DeleteParentButNotChild() async throws { + let parent = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + let child = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) + child.parent = CKRecord.Reference(record: parent, action: .none) + + _ = try syncEngine.private.database.modifyRecords(saving: [child, parent]) + let (_, deleteResults) = try syncEngine.private.database.modifyRecords( + deleting: [parent.recordID] + ) + let error = #expect(throws: CKError.self) { + try deleteResults[CKRecord.ID(recordName: "A")]?.get() + } + #expect(error == CKError(.referenceViolation)) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__), + recordType: "A", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(B/_defaultZone/__defaultOwner__), + recordType: "B", + parent: CKReference(recordID: CKRecord.ID(A/_defaultZone/__defaultOwner__)), + share: nil + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } - let newShare = try syncEngine.private.database.record(for: CKRecord.ID(recordName: "share")) - _ = try syncEngine.modifyRecords(scope: .private, saving: [newShare]) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteUnknownZone() async throws { + let (_, deleteResults) = try syncEngine.private.database.modifyRecordZones( + saving: [], + deleting: [CKRecordZone.ID(zoneName: "unknownZone")] + ) + let error = #expect(throws: CKError.self) { + try deleteResults[CKRecordZone.ID(zoneName: "unknownZone")]?.get() + } + #expect(error == CKError(.zoneNotFound)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func accountTemporarilyAvailable() async throws { + container._accountStatus.withValue { $0 = .temporarilyUnavailable } + var error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecordZones() + } + #expect(error == CKError(.accountTemporarilyUnavailable)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecords() + } + #expect(error == CKError(.accountTemporarilyUnavailable)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) + } + #expect(error == CKError(.accountTemporarilyUnavailable)) + error = await #expect(throws: CKError.self) { + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) + } + #expect(error == CKError(.accountTemporarilyUnavailable)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func noAccount() async throws { + container._accountStatus.withValue { $0 = .noAccount } + var error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecordZones() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecords() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) + } + #expect(error == CKError(.notAuthenticated)) + error = await #expect(throws: CKError.self) { + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) + } + #expect(error == CKError(.notAuthenticated)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func accountNotDetermined() async throws { + container._accountStatus.withValue { $0 = .couldNotDetermine } + var error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecordZones() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecords() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) + } + #expect(error == CKError(.notAuthenticated)) + error = await #expect(throws: CKError.self) { + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) + } + #expect(error == CKError(.notAuthenticated)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func restrictedAccount() async throws { + container._accountStatus.withValue { $0 = .restricted } + var error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecordZones() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.modifyRecords() + } + #expect(error == CKError(.notAuthenticated)) + error = #expect(throws: CKError.self) { + _ = try self.syncEngine.private.database.record(for: CKRecord.ID(recordName: "test")) + } + #expect(error == CKError(.notAuthenticated)) + error = await #expect(throws: CKError.self) { + _ = try await self.syncEngine.private.database.records(for: [ + CKRecord.ID(recordName: "test") + ]) + } + #expect(error == CKError(.notAuthenticated)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func incorrectlyCreatingNewRecordIdentity() async throws { + let record1 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [record1]) + let record2 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) + try withKnownIssue { + _ = try syncEngine.modifyRecords(scope: .private, saving: [record2]) + } matching: { issue in + issue.description == """ + Issue recorded: A new identity was created for an existing 'CKRecord' ('1'). Rather than \ + creating 'CKRecord' from scratch for an existing record, use the database to fetch the \ + current record. + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveShareWithoutRootRecord() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + try withKnownIssue { + _ = try syncEngine.modifyRecords(scope: .private, saving: [share]) + } matching: { issue in + issue.description == """ + Issue recorded: An added share is being saved without its rootRecord being saved in the \ + same operation. + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveShareAndRootThenSaveShareAlone() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [share, record]) + + let newShare = try syncEngine.private.database.record(for: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [newShare]) + } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index 40165898..e2577b62 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -1,38 +1,86 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing -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) + 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) + } } } + ) + } + + @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: [] + ) + ) + """ } - ) - } - @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( + let metadata = try await userDatabase.userRead { db in + try SyncMetadata.order(by: \.recordName).fetchAll(db) + } + assertInlineSnapshot(of: metadata, as: .customDump) { + """ + [ + [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__)), @@ -42,87 +90,41 @@ extension BaseCloudKitTests { remindersListID: 1, title: "Write blog post" ), - [1]: CKRecord( + 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" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - let metadata = try await userDatabase.userRead { db in - try SyncMetadata.order(by: \.recordName).fetchAll(db) - } - assertInlineSnapshot(of: metadata, as: .customDump) { - """ - [ - [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) - ) - ] - """ + _isDeleted: false, + isShared: false, + userModificationDate: Date(1970-01-01T00:00:00.000Z) + ) + ] + """ + } } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 6f1855df..93650a97 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -1,222 +1,224 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func noMetadataForRecord() async throws { - syncEngine.private.state.add( - pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: 1))] - ) - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + extension BaseCloudKitTests { + @MainActor + final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func noMetadataForRecord() async throws { + syncEngine.private.state.add( + pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: 1))] ) - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func nonExistentTable() async throws { - try await userDatabase.userWrite { db in - try SyncMetadata.insert { - SyncMetadata( - recordPrimaryKey: "1", - recordType: UnrecognizedTable.tableName, - userModificationDate: .distantPast + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) + """ } - .execute(db) } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func nonExistentTable() async throws { + try await userDatabase.userWrite { db in + try SyncMetadata.insert { + SyncMetadata( + recordPrimaryKey: "1", + recordType: UnrecognizedTable.tableName, + userModificationDate: .distantPast + ) + } + .execute(db) + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func metadataRowWithNoCorrespondingRecordRow() async throws { - try await userDatabase.userWrite { db in - try SyncMetadata.insert { - SyncMetadata( - recordPrimaryKey: "1", - recordType: RemindersList.tableName, - userModificationDate: .distantPast + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) + """ } - .execute(db) } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func metadataRowWithNoCorrespondingRecordRow() async throws { + try await userDatabase.userWrite { db in + try SyncMetadata.insert { + SyncMetadata( + recordPrimaryKey: "1", + recordType: RemindersList.tableName, + userModificationDate: .distantPast + ) + } + .execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func saveRecord() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } } - } - 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: [] + 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: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func saveRecordWithParent() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveRecordWithParent() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } - } - 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: "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 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: "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: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func savePrivateRecord() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersListPrivate(id: 1, position: 42, remindersListID: 1) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func savePrivateRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersListPrivate(id: 1, position: 42, remindersListID: 1) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), - recordType: "remindersListPrivates", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - position: 42, - remindersListID: 1 - ), - [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 syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListPrivates/zone/__defaultOwner__), + recordType: "remindersListPrivates", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + position: 42, + remindersListID: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } } } -} -@Table struct UnrecognizedTable { - let id: UUID -} + @Table struct UnrecognizedTable { + let id: UUID + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 992362ab..c25321aa 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -1,134 +1,515 @@ -import CloudKit -import ConcurrencyExtras -import CustomDump -import InlineSnapshotTesting -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - 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 - try RecordType.all.fetchAll(db) + extension BaseCloudKitTests { + @MainActor + 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 + try RecordType.all.fetchAll(db) + } + assertInlineSnapshot(of: recordTypes, as: .customDump) { + #""" + [ + [0]: RecordType( + tableName: "remindersLists", + schema: """ + CREATE TABLE "remindersLists" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] + ), + [1]: RecordType( + tableName: "sqlite_sequence", + schema: "CREATE TABLE sqlite_sequence(name,seq)", + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "name", + notNull: false, + type: "" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "seq", + notNull: false, + type: "" + ) + ] + ), + [2]: RecordType( + tableName: "remindersListAssets", + schema: """ + CREATE TABLE "remindersListAssets" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "coverImage" BLOB NOT NULL, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "coverImage", + notNull: true, + type: "BLOB" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "INTEGER" + ) + ] + ), + [3]: RecordType( + tableName: "remindersListPrivates", + schema: """ + CREATE TABLE "remindersListPrivates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "position", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "INTEGER" + ) + ] + ), + [4]: RecordType( + tableName: "reminders", + schema: """ + CREATE TABLE "reminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "dueDate" TEXT, + "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "priority" INTEGER, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "remindersListID" INTEGER NOT NULL, + + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "dueDate", + notNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isCompleted", + notNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "priority", + notNull: false, + type: "INTEGER" + ), + [4]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "remindersListID", + notNull: true, + type: "INTEGER" + ), + [5]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] + ), + [5]: RecordType( + tableName: "tags", + schema: """ + CREATE TABLE "tags" ( + "title" TEXT PRIMARY KEY NOT NULL COLLATE NOCASE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "title", + notNull: true, + type: "TEXT" + ) + ] + ), + [6]: RecordType( + tableName: "reminderTags", + schema: """ + CREATE TABLE "reminderTags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "reminderID", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "tagID", + notNull: true, + type: "TEXT" + ) + ] + ), + [7]: RecordType( + tableName: "parents", + schema: """ + CREATE TABLE "parents"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ) + ] + ), + [8]: RecordType( + tableName: "childWithOnDeleteSetNulls", + schema: """ + CREATE TABLE "childWithOnDeleteSetNulls"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "INTEGER" + ) + ] + ), + [9]: RecordType( + tableName: "childWithOnDeleteSetDefaults", + schema: """ + CREATE TABLE "childWithOnDeleteSetDefaults"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER NOT NULL DEFAULT 0 + REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "parentID", + notNull: true, + type: "INTEGER" + ) + ] + ), + [10]: RecordType( + tableName: "localUsers", + schema: """ + CREATE TABLE "localUsers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "name", + notNull: true, + type: "TEXT" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "parentID", + notNull: false, + type: "INTEGER" + ) + ] + ), + [11]: RecordType( + tableName: "modelAs", + schema: """ + CREATE TABLE "modelAs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "count", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ) + ] + ), + [12]: RecordType( + tableName: "modelBs", + schema: """ + CREATE TABLE "modelBs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, + "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isOn", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelAID", + notNull: true, + type: "INTEGER" + ) + ] + ), + [13]: RecordType( + tableName: "modelCs", + schema: """ + CREATE TABLE "modelCs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "modelBID", + notNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + notNull: true, + type: "TEXT" + ) + ] + ), + [14]: RecordType( + tableName: "unsyncedModels", + schema: """ + CREATE TABLE "unsyncedModels" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + notNull: true, + type: "INTEGER" + ) + ] + ) + ] + """# + } } - assertInlineSnapshot(of: recordTypes, as: .customDump) { - #""" - [ - [0]: RecordType( - tableName: "remindersLists", - schema: """ - CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, - name: "title", - notNull: true, - type: "TEXT" - ) - ] - ), - [1]: RecordType( - tableName: "sqlite_sequence", - schema: "CREATE TABLE sqlite_sequence(name,seq)", - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "name", - notNull: false, - type: "" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "seq", - notNull: false, - type: "" - ) - ] - ), - [2]: RecordType( - tableName: "remindersListAssets", - schema: """ - CREATE TABLE "remindersListAssets" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "coverImage" BLOB NOT NULL, - "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "coverImage", - notNull: true, - type: "BLOB" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [2]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "remindersListID", - notNull: true, - type: "INTEGER" - ) - ] - ), - [3]: RecordType( - tableName: "remindersListPrivates", - schema: """ - CREATE TABLE "remindersListPrivates" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, - "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "0", - isPrimaryKey: false, - name: "position", - notNull: true, - type: "INTEGER" - ), - [2]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "remindersListID", - notNull: true, - type: "INTEGER" - ) - ] - ), - [4]: RecordType( + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tearDown() async throws { + try syncEngine.tearDownSyncEngine() + try await userDatabase.userRead { db in + try #expect(RecordType.all.fetchAll(db) == []) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func resetUp() async throws { + let recordTypes = try await userDatabase.userRead { 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 + try RecordType.all.fetchAll(db) + } + expectNoDifference(recordTypes, recordTypesAfterReSetup) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func migration() async throws { + let recordTypes = try await userDatabase.userRead { db in + try RecordType.order(by: \.tableName).fetchAll(db) + } + syncEngine.stop() + try syncEngine.tearDownSyncEngine() + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "reminders" ADD COLUMN "newFeature" INTEGER NOT NULL + """ + ) + .execute(db) + } + try syncEngine.setUpSyncEngine() + try await syncEngine.start() + + let recordTypesAfterMigration = try await userDatabase.userRead { db in + try RecordType.order(by: \.tableName).fetchAll(db) + } + let remindersTableIndex = try #require( + recordTypesAfterMigration.firstIndex { $0.tableName == Reminder.tableName } + ) + #expect( + recordTypes[0.. When data is synchronized the reminder and list are deleted. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func moveReminderToList_RemoteDeletesList() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let modifications = try syncEngine.modifyRecords( - scope: .private, - deleting: [RemindersList.recordID(for: 2)] - ) - try withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - try userDatabase.userWrite { db in - try Reminder.find(1).update { $0.remindersListID = 2 }.execute(db) + extension BaseCloudKitTests { + @MainActor + final class ReferenceViolationTests: BaseCloudKitTests, @unchecked Sendable { + // * Local client moves a reminder to a list. + // * At same time, remote deletes that list. + // => When data is synchronized the reminder and list are deleted. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func moveReminderToList_RemoteDeletesList() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modifications.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.read { db in - try #expect(Reminder.find(1).fetchCount(db) == 0) - try #expect(RemindersList.find(2).fetchCount(db) == 0) - } - 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 modifications = try syncEngine.modifyRecords( + scope: .private, + deleting: [RemindersList.recordID(for: 2)] ) - """ - } + try withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try userDatabase.userWrite { db in + try Reminder.find(1).update { $0.remindersListID = 2 }.execute(db) + } + } - try await userDatabase.read { db in - try #expect(Reminder.count().fetchOne(db) == 0) - try #expect( - RemindersList.all.fetchAll(db) == [ - RemindersList(id: 1, title: "Personal") - ] - ) - } - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - // * Local client deletes a list - // * At the same time, remote adds a reminder to that list. - // * Local data is sync'd first, then remote data syncs. - // => Deletion is rejected and the list and reminder are sync'd to local client. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteList_RemoteAddsReminderToList() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") + try await userDatabase.read { db in + try #expect(Reminder.find(1).fetchCount(db) == 0) + try #expect(RemindersList.find(2).fetchCount(db) == 0) } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) + 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 modifications = try withDependencies { - $0.datetime.now.addTimeInterval(2) - } operation: { - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 1), - action: .none - ) - return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modifications.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" - ) + try await userDatabase.read { db in + try #expect(Reminder.count().fetchOne(db) == 0) + try #expect( + RemindersList.all.fetchAll(db) == [ + RemindersList(id: 1, title: "Personal") ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) - ) - """ - } - - try await userDatabase.read { db in - try #expect( - Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] - ) - try #expect( - RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] - ) - } - } - - // * Local client deletes a list - // * At the same time, remote adds a reminder to that list. - // * Remote data is sync'd first, then local data syncs. - // => Deletion is rejected and the list and reminder are sync'd to local client. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteList_RemoteAddsReminderToList_Variation() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { + // * Local client deletes a list + // * At the same time, remote adds a reminder to that list. + // * Local data is sync'd first, then remote data syncs. + // => Deletion is rejected and the list and reminder are sync'd to local client. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteList_RemoteAddsReminderToList() async throws { try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) + try db.seed { + RemindersList(id: 1, title: "Personal") + } } - } - let modifications = try withDependencies { - $0.datetime.now.addTimeInterval(2) - } operation: { - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference( - recordID: RemindersList.recordID(for: 1), - action: .none - ) - return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) - } - await modifications.notify() - 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, - 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 - try #expect( - Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] - ) - try #expect( - RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] - ) - } - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - // * Local client move child to parent. - // * Remote client deletes parent. - // * Local data is sync'd first, then remote data syncs. - // => Local client sets parent relationship to NULL and parent is deleted. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func moveChildToParent_RemoteDeletesParent_CascadeSetNull() async throws { - try await userDatabase.userWrite { db in - try db.seed { - Parent(id: 1) - Parent(id: 2) - ChildWithOnDeleteSetNull(id: 1, parentID: 1) + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let modifications = try syncEngine.modifyRecords( - scope: .private, - deleting: [Parent.recordID(for: 2)] - ) - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - try await userDatabase.userWrite { db in - try ChildWithOnDeleteSetNull.find(1).update { $0.parentID = 2 }.execute(db) + let modifications = try withDependencies { + $0.datetime.now.addTimeInterval(2) + } operation: { + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) } - } - try await withDependencies { - $0.datetime.now.addTimeInterval(2) - } operation: { try await syncEngine.processPendingRecordZoneChanges(scope: .private) await modifications.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { """ @@ -281,18 +124,21 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/zone/__defaultOwner__), - recordType: "childWithOnDeleteSetNulls", - parent: nil, + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, - id: 1 + id: 1, + remindersListID: 1, + title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(1:parents/zone/__defaultOwner__), - recordType: "parents", + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", parent: nil, share: nil, - id: 1 + id: 1, + title: "Personal" ) ] ), @@ -303,52 +149,53 @@ extension BaseCloudKitTests { ) """ } + try await userDatabase.read { db in try #expect( - ChildWithOnDeleteSetNull.all.fetchAll(db) == [ - ChildWithOnDeleteSetNull(id: 1, parentID: nil) - ] + Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] ) try #expect( - Parent.all.fetchAll(db) == [ - Parent(id: 1) - ] + RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] ) } } - } - // * Local client move child to parent. - // * Remote client deletes parent. - // * Local data is sync'd first, then remote data syncs. - // => Local client sets parent relationship to default value. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func moveChildToParent_RemoteDeletesParent_CascadeSetDefault() async throws { - try await userDatabase.userWrite { db in - try db.seed { - Parent(id: 0) - Parent(id: 1) - Parent(id: 2) - ChildWithOnDeleteSetDefault(id: 1, parentID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let modifications = try syncEngine.modifyRecords( - scope: .private, - deleting: [Parent.recordID(for: 2)] - ) - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { + // * Local client deletes a list + // * At the same time, remote adds a reminder to that list. + // * Remote data is sync'd first, then local data syncs. + // => Deletion is rejected and the list and reminder are sync'd to local client. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteList_RemoteAddsReminderToList_Variation() async throws { try await userDatabase.userWrite { db in - try ChildWithOnDeleteSetDefault.find(1).update { $0.parentID = 2 }.execute(db) + try db.seed { + RemindersList(id: 1, title: "Personal") + } } - } - try await withDependencies { - $0.datetime.now.addTimeInterval(2) - } operation: { try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + } + let modifications = try withDependencies { + $0.datetime.now.addTimeInterval(2) + } operation: { + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + recordID: RemindersList.recordID(for: 1), + action: .none + ) + return try syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]) + } await modifications.notify() try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -359,26 +206,21 @@ extension BaseCloudKitTests { databaseScope: .private, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:childWithOnDeleteSetDefaults/zone/__defaultOwner__), - recordType: "childWithOnDeleteSetDefaults", - parent: CKReference(recordID: CKRecord.ID(0:parents/zone/__defaultOwner__)), + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), share: nil, id: 1, - parentID: 0 + remindersListID: 1, + title: "Get milk" ), [1]: CKRecord( - recordID: CKRecord.ID(0:parents/zone/__defaultOwner__), - recordType: "parents", + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", parent: nil, share: nil, - id: 0 - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:parents/zone/__defaultOwner__), - recordType: "parents", - parent: nil, - share: nil, - id: 1 + id: 1, + title: "Personal" ) ] ), @@ -389,17 +231,177 @@ extension BaseCloudKitTests { ) """ } + try await userDatabase.read { db in try #expect( - ChildWithOnDeleteSetDefault.all.fetchAll(db) == [ - ChildWithOnDeleteSetDefault(id: 1, parentID: 0) - ] + Reminder.all.fetchAll(db) == [Reminder(id: 1, title: "Get milk", remindersListID: 1)] ) try #expect( - Parent.all.fetchAll(db) == [Parent(id: 0), Parent(id: 1)] + RemindersList.all.fetchAll(db) == [RemindersList(id: 1, title: "Personal")] ) } } + + // * Local client move child to parent. + // * Remote client deletes parent. + // * Local data is sync'd first, then remote data syncs. + // => Local client sets parent relationship to NULL and parent is deleted. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func moveChildToParent_RemoteDeletesParent_CascadeSetNull() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Parent(id: 1) + Parent(id: 2) + ChildWithOnDeleteSetNull(id: 1, parentID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let modifications = try syncEngine.modifyRecords( + scope: .private, + deleting: [Parent.recordID(for: 2)] + ) + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try ChildWithOnDeleteSetNull.find(1).update { $0.parentID = 2 }.execute(db) + } + } + try await withDependencies { + $0.datetime.now.addTimeInterval(2) + } operation: { + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteSetNulls/zone/__defaultOwner__), + recordType: "childWithOnDeleteSetNulls", + parent: nil, + share: nil, + id: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:parents/zone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + try await userDatabase.read { db in + try #expect( + ChildWithOnDeleteSetNull.all.fetchAll(db) == [ + ChildWithOnDeleteSetNull(id: 1, parentID: nil) + ] + ) + try #expect( + Parent.all.fetchAll(db) == [ + Parent(id: 1) + ] + ) + } + } + } + + // * Local client move child to parent. + // * Remote client deletes parent. + // * Local data is sync'd first, then remote data syncs. + // => Local client sets parent relationship to default value. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func moveChildToParent_RemoteDeletesParent_CascadeSetDefault() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Parent(id: 0) + Parent(id: 1) + Parent(id: 2) + ChildWithOnDeleteSetDefault(id: 1, parentID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let modifications = try syncEngine.modifyRecords( + scope: .private, + deleting: [Parent.recordID(for: 2)] + ) + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try ChildWithOnDeleteSetDefault.find(1).update { $0.parentID = 2 }.execute(db) + } + } + try await withDependencies { + $0.datetime.now.addTimeInterval(2) + } operation: { + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:childWithOnDeleteSetDefaults/zone/__defaultOwner__), + recordType: "childWithOnDeleteSetDefaults", + parent: CKReference(recordID: CKRecord.ID(0:parents/zone/__defaultOwner__)), + share: nil, + id: 1, + parentID: 0 + ), + [1]: CKRecord( + recordID: CKRecord.ID(0:parents/zone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 0 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:parents/zone/__defaultOwner__), + recordType: "parents", + parent: nil, + share: nil, + id: 1 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + try await userDatabase.read { db in + try #expect( + ChildWithOnDeleteSetDefault.all.fetchAll(db) == [ + ChildWithOnDeleteSetDefault(id: 1, parentID: 0) + ] + ) + try #expect( + Parent.all.fetchAll(db) == [Parent(id: 0), Parent(id: 1)] + ) + } + } + } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index 35e16200..ebd7d64f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -1,324 +1,326 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import SQLiteData -import SnapshotTestingCustomDump -import Testing - -extension BaseCloudKitTests { - @MainActor - final class SchemaChangeTests: BaseCloudKitTests, @unchecked Sendable { - @Dependency(\.dataManager) var dataManager - var inMemoryDataManager: InMemoryDataManager { - dataManager as! InMemoryDataManager - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addColumnToRemindersAndRemindersLists() async throws { - let personalList = RemindersList(id: 1, title: "Personal") - let businessList = RemindersList(id: 2, title: "Business") - let reminder = Reminder(id: 1, title: "Get milk", remindersListID: 1) - try await userDatabase.userWrite { db in - try db.seed { - personalList - businessList - reminder - } +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class SchemaChangeTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.dataManager) var dataManager + var inMemoryDataManager: InMemoryDataManager { + dataManager as! InMemoryDataManager } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addColumnToRemindersAndRemindersLists() async throws { + let personalList = RemindersList(id: 1, title: "Personal") + let businessList = RemindersList(id: 2, title: "Business") + let reminder = Reminder(id: 1, title: "Get milk", remindersListID: 1) + try await userDatabase.userWrite { db in + try db.seed { + personalList + businessList + reminder + } + } - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { - let personalListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: 1) - ) - personalListRecord.setValue(1, forKey: "position", at: now) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let businessListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: 2) - ) - businessListRecord.setValue(2, forKey: "position", at: now) + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + let personalListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + personalListRecord.setValue(1, forKey: "position", at: now) - let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: 1) - ) - reminderRecord.setValue(3, forKey: "position", at: now) + let businessListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 2) + ) + businessListRecord.setValue(2, forKey: "position", at: now) - try await syncEngine.modifyRecords( - scope: .private, - saving: [personalListRecord, businessListRecord, reminderRecord] - ) - .notify() + let reminderRecord = try syncEngine.private.database.record( + for: Reminder.recordID(for: 1) + ) + reminderRecord.setValue(3, forKey: "position", at: now) - try await userDatabase.userWrite { db in - try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 - """ + try await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord, businessListRecord, reminderRecord] ) - .execute(db) - try #sql( - """ - ALTER TABLE "reminders" - ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 - """ + .notify() + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + """ + ) + .execute(db) + try #sql( + """ + ALTER TABLE "reminders" + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0 != Reminder.self && $0 != RemindersList.self } + + [ReminderWithPosition.self, RemindersListWithPosition.self], + privateTables: syncEngine.privateTables + ) + defer { _ = relaunchedSyncEngine } + + let remindersLists = try await userDatabase.userRead { db in + try RemindersListWithPosition.order(by: \.id).fetchAll(db) + } + let reminders = try await userDatabase.userRead { db in + try ReminderWithPosition.order(by: \.id).fetchAll(db) + } + + expectNoDifference( + remindersLists, + [ + RemindersListWithPosition(id: 1, title: "Personal", position: 1), + RemindersListWithPosition(id: 2, title: "Business", position: 2), + ] + ) + expectNoDifference( + reminders, + [ + ReminderWithPosition( + id: 1, + title: "Get milk", + position: 3, + remindersListID: 1 + ) + ] ) - .execute(db) - } - - let relaunchedSyncEngine = try await SyncEngine( - container: syncEngine.container, - userDatabase: syncEngine.userDatabase, - tables: syncEngine.tables - .filter { $0 != Reminder.self && $0 != RemindersList.self } - + [ReminderWithPosition.self, RemindersListWithPosition.self], - privateTables: syncEngine.privateTables - ) - defer { _ = relaunchedSyncEngine } - - let remindersLists = try await userDatabase.userRead { db in - try RemindersListWithPosition.order(by: \.id).fetchAll(db) - } - let reminders = try await userDatabase.userRead { db in - try ReminderWithPosition.order(by: \.id).fetchAll(db) } - - expectNoDifference( - remindersLists, - [ - RemindersListWithPosition(id: 1, title: "Personal", position: 1), - RemindersListWithPosition(id: 2, title: "Business", position: 2), - ] - ) - expectNoDifference( - reminders, - [ - ReminderWithPosition( - id: 1, - title: "Get milk", - position: 3, - remindersListID: 1 - ) - ] - ) } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addAssetToRemindersList() async throws { - let personalList = RemindersList(id: 1, title: "Personal") - try await userDatabase.userWrite { db in - try db.seed { - personalList + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addAssetToRemindersList() async throws { + let personalList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + personalList + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { - let personalListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: 1) - ) - personalListRecord.setValue(Array("image".utf8), forKey: "image", at: now) - - try await syncEngine.modifyRecords( - scope: .private, - saving: [personalListRecord] - ) - .notify() + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + let personalListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + personalListRecord.setValue(Array("image".utf8), forKey: "image", at: now) - try await userDatabase.userWrite { db in - try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' - """ + try await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord] ) - .execute(db) - } + .notify() + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0 != RemindersList.self } + + [RemindersListWithData.self], + privateTables: syncEngine.privateTables + ) + defer { _ = relaunchedSyncEngine } - let relaunchedSyncEngine = try await SyncEngine( - container: syncEngine.container, - userDatabase: syncEngine.userDatabase, - tables: syncEngine.tables - .filter { $0 != RemindersList.self } - + [RemindersListWithData.self], - privateTables: syncEngine.privateTables - ) - defer { _ = relaunchedSyncEngine } - - let remindersLists = try await userDatabase.userRead { db in - try RemindersListWithData.order(by: \.id).fetchAll(db) - } + let remindersLists = try await userDatabase.userRead { db in + try RemindersListWithData.order(by: \.id).fetchAll(db) + } - expectNoDifference( - remindersLists, - [ - RemindersListWithData(id: 1, image: Data("image".utf8), title: "Personal") - ] - ) + expectNoDifference( + remindersLists, + [ + RemindersListWithData(id: 1, image: Data("image".utf8), title: "Personal") + ] + ) + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addAssetToRemindersList_Redownload() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - RemindersList(id: 2, title: "Business") - RemindersList(id: 3, title: "Secret") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addAssetToRemindersList_Redownload() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Business") + RemindersList(id: 3, title: "Secret") + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { - let personalListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: 1) - ) - personalListRecord.setValue(Array("personal-image".utf8), forKey: "image", at: now) - let businessListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: 2) - ) - businessListRecord.setValue(Array("business-image".utf8), forKey: "image", at: now) - let secretListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: 3) - ) - secretListRecord.setValue(Array("secret-image".utf8), forKey: "image", at: now) - - try await syncEngine.modifyRecords( - scope: .private, - saving: [personalListRecord, businessListRecord, secretListRecord] - ) - .notify() - - inMemoryDataManager.storage.withValue { $0.removeAll() } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.userWrite { db in - try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' - """ + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + let personalListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) ) - .execute(db) - } + personalListRecord.setValue(Array("personal-image".utf8), forKey: "image", at: now) + let businessListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 2) + ) + businessListRecord.setValue(Array("business-image".utf8), forKey: "image", at: now) + let secretListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 3) + ) + secretListRecord.setValue(Array("secret-image".utf8), forKey: "image", at: now) - let relaunchedSyncEngine = try await SyncEngine( - container: syncEngine.container, - userDatabase: syncEngine.userDatabase, - tables: syncEngine.tables - .filter { $0 != RemindersList.self } - + [RemindersListWithData.self], - privateTables: syncEngine.privateTables - ) - defer { _ = relaunchedSyncEngine } - - let remindersLists = try await userDatabase.userRead { db in - try RemindersListWithData.order(by: \.id).fetchAll(db) - } + try await syncEngine.modifyRecords( + scope: .private, + saving: [personalListRecord, businessListRecord, secretListRecord] + ) + .notify() + + inMemoryDataManager.storage.withValue { $0.removeAll() } - expectNoDifference( - remindersLists, - [ - RemindersListWithData(id: 1, image: Data("personal-image".utf8), title: "Personal"), - RemindersListWithData(id: 2, image: Data("business-image".utf8), title: "Business"), - RemindersListWithData(id: 3, image: Data("secret-image".utf8), title: "Secret"), - ] - ) + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "image" BLOB NOT NULL ON CONFLICT REPLACE DEFAULT X'' + """ + ) + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0 != RemindersList.self } + + [RemindersListWithData.self], + privateTables: syncEngine.privateTables + ) + defer { _ = relaunchedSyncEngine } + + let remindersLists = try await userDatabase.userRead { db in + try RemindersListWithData.order(by: \.id).fetchAll(db) + } + + expectNoDifference( + remindersLists, + [ + RemindersListWithData(id: 1, image: Data("personal-image".utf8), title: "Personal"), + RemindersListWithData(id: 2, image: Data("business-image".utf8), title: "Business"), + RemindersListWithData(id: 3, image: Data("secret-image".utf8), title: "Secret"), + ] + ) + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func newTable() async throws { - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { - let imageRecord = CKRecord( - recordType: "images", - recordID: Image.recordID(for: 1) - ) - imageRecord.setValue("1", forKey: "id", at: now) - imageRecord.setValue("A good image", forKey: "caption", at: now) - imageRecord.setValue(Data("image".utf8), forKey: "image", at: now) - - try await syncEngine.modifyRecords( - scope: .private, - saving: [imageRecord] - ) - .notify() - - inMemoryDataManager.storage.withValue { $0.removeAll() } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func newTable() async throws { + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + let imageRecord = CKRecord( + recordType: "images", + recordID: Image.recordID(for: 1) + ) + imageRecord.setValue("1", forKey: "id", at: now) + imageRecord.setValue("A good image", forKey: "caption", at: now) + imageRecord.setValue(Data("image".utf8), forKey: "image", at: now) - try await userDatabase.userWrite { db in - try #sql( - """ - CREATE TABLE "images" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), - "caption" TEXT NOT NULL, - "image" BLOB NOT NULL + try await syncEngine.modifyRecords( + scope: .private, + saving: [imageRecord] + ) + .notify() + + inMemoryDataManager.storage.withValue { $0.removeAll() } + + try await userDatabase.userWrite { db in + try #sql( + """ + CREATE TABLE "images" ( + "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "caption" TEXT NOT NULL, + "image" BLOB NOT NULL + ) + """ ) - """ + .execute(db) + } + + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + [Image.self], + privateTables: syncEngine.privateTables ) - .execute(db) - } + defer { _ = relaunchedSyncEngine } - let relaunchedSyncEngine = try await SyncEngine( - container: syncEngine.container, - userDatabase: syncEngine.userDatabase, - tables: syncEngine.tables + [Image.self], - privateTables: syncEngine.privateTables - ) - defer { _ = relaunchedSyncEngine } + let images = try await userDatabase.userRead { db in + try Image.order(by: \.id).fetchAll(db) + } - let images = try await userDatabase.userRead { db in - try Image.order(by: \.id).fetchAll(db) + expectNoDifference( + images, + [ + Image(id: 1, image: Data("image".utf8), caption: "A good image") + ] + ) } - - expectNoDifference( - images, - [ - Image(id: 1, image: Data("image".utf8), caption: "A good image") - ] - ) } } } -} - -@Table("remindersLists") -private struct RemindersListWithPosition: Equatable, Identifiable { - let id: Int - var title = "" - var position = 0 -} - -@Table("reminders") -private struct ReminderWithPosition: Equatable, Identifiable { - let id: Int - var title = "" - var position = 0 - var remindersListID: RemindersList.ID -} - -@Table("remindersLists") -private struct RemindersListWithData: Equatable, Identifiable { - let id: Int - var image: Data - var title = "" -} - -@Table -private struct Image: Equatable, Identifiable { - let id: Int - var image: Data - var caption = "" -} + + @Table("remindersLists") + private struct RemindersListWithPosition: Equatable, Identifiable { + let id: Int + var title = "" + var position = 0 + } + + @Table("reminders") + private struct ReminderWithPosition: Equatable, Identifiable { + let id: Int + var title = "" + var position = 0 + var remindersListID: RemindersList.ID + } + + @Table("remindersLists") + private struct RemindersListWithData: Equatable, Identifiable { + let id: Int + var image: Data + var title = "" + } + + @Table + private struct Image: Equatable, Identifiable { + let id: Int + var image: Data + var caption = "" + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift index 9ac28253..4457e490 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift @@ -1,468 +1,470 @@ -import CloudKit -import CustomDump -import Foundation -import GRDB -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import GRDB + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - final class SharingPermissionsTests: BaseCloudKitTests, @unchecked Sendable { - /// Inserting record into shared record when user does not have permission should be rejected. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func insertRecordInReadOnlyRemindersList() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" + extension BaseCloudKitTests { + @MainActor + final class SharingPermissionsTests: BaseCloudKitTests, @unchecked Sendable { + /// Inserting record into shared record when user does not have permission should be rejected. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertRecordInReadOnlyRemindersList() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) ) - ) - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + 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 + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) ) - ) - share.publicPermission = .readOnly - share.currentUserParticipant?.permission = .readOnly - - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share + 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 ) ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) - try await self.userDatabase.userWrite { db in - let error = #expect(throws: DatabaseError.self) { - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.all.fetchCount(db) == 0) } - #expect(error?.message == SyncEngine.writePermissionError) - try #expect(Reminder.all.fetchCount(db) == 0) - } - 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" - ) - ] + 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" + ) + ] + ) ) - ) - """ + """ + } } - } - /// Delete record in shared record when user does not have permission. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteReminderInReadOnlyRemindersList() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" + /// Delete record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteReminderInReadOnlyRemindersList() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) ) - ) - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + 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 reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - - _ = try syncEngine.modifyRecords( - scope: .shared, - saving: [reminderRecord, remindersListRecord] - ) + 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 reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - let freshRemindersListRecord = try syncEngine.shared.database.record( - for: remindersListRecord.recordID - ) + _ = try syncEngine.modifyRecords( + scope: .shared, + saving: [reminderRecord, remindersListRecord] + ) - let share = CKShare( - rootRecord: freshRemindersListRecord, - shareID: CKRecord.ID( - recordName: "share-\(freshRemindersListRecord.recordID.recordName)", - zoneID: freshRemindersListRecord.recordID.zoneID + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID ) - ) - share.publicPermission = .readOnly - share.currentUserParticipant?.permission = .readOnly - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: freshRemindersListRecord.recordID, - rootRecord: freshRemindersListRecord, - share: share + let share = CKShare( + rootRecord: freshRemindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(freshRemindersListRecord.recordID.recordName)", + zoneID: freshRemindersListRecord.recordID.zoneID ) ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly - try await self.userDatabase.userWrite { db in - let error = #expect(throws: DatabaseError.self) { - try Reminder.find(1).delete().execute(db) + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: share + ) + ) + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.find(1).delete().execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.count().fetchOne(db) == 1) } - #expect(error?.message == SyncEngine.writePermissionError) - try #expect(Reminder.count().fetchOne(db) == 1) - } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:reminders/external.zone/external.owner), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), - share: nil, - id: 1, - 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" - ) - ] + 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, + 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" + ) + ] + ) ) - ) - """ + """ + } } - } - /// Editing record in shared record when user does not have permission. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func editReminderInReadOnlyRemindersList() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" + /// Editing record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editReminderInReadOnlyRemindersList() 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 reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.setValue(false, forKey: "isCompleted", at: now) - reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - _ = try syncEngine.modifyRecords( - scope: .shared, - saving: [remindersListRecord, reminderRecord] - ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - let freshRemindersListRecord = try syncEngine.shared.database.record( - for: remindersListRecord.recordID - ) - let share = CKShare( - rootRecord: freshRemindersListRecord, - shareID: CKRecord.ID( - recordName: "share-\(freshRemindersListRecord.recordID.recordName)", - zoneID: freshRemindersListRecord.recordID.zoneID + 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 reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.setValue(false, forKey: "isCompleted", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + _ = try syncEngine.modifyRecords( + scope: .shared, + saving: [remindersListRecord, reminderRecord] ) - ) - share.publicPermission = .readOnly - share.currentUserParticipant?.permission = .readOnly - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: freshRemindersListRecord.recordID, - rootRecord: freshRemindersListRecord, - share: share + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + let share = CKShare( + rootRecord: freshRemindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(freshRemindersListRecord.recordID.recordName)", + zoneID: freshRemindersListRecord.recordID.zoneID ) ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: share + ) + ) - try await self.userDatabase.userWrite { db in - let error = #expect(throws: DatabaseError.self) { - try Reminder.update { $0.isCompleted = true }.execute(db) + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.update { $0.isCompleted = true }.execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.where(\.isCompleted).fetchCount(db) == 0) } - #expect(error?.message == SyncEngine.writePermissionError) - try #expect(Reminder.where(\.isCompleted).fetchCount(db) == 0) - } - 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" - ) - ] + 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" + ) + ] + ) ) - ) - """ + """ + } } - } - // Edit a record while locally we think we have permission, but CloudKit has newer permissions - // that are read only. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func createRecordWhenLocalHasPermissionsButCloudKitDoesNot() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" + // Edit a record while locally we think we have permission, but CloudKit has newer permissions + // that are read only. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createRecordWhenLocalHasPermissionsButCloudKitDoesNot() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) ) - ) - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + 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 + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) ) - ) - share.publicPermission = .readWrite - share.currentUserParticipant?.permission = .readWrite - - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share + 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 ) ) + share.publicPermission = .readWrite + share.currentUserParticipant?.permission = .readWrite - let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare - freshShare.publicPermission = .readOnly - freshShare.currentUserParticipant?.permission = .readOnly - let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - try await self.userDatabase.userWrite { db in - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + freshShare.publicPermission = .readOnly + freshShare.currentUserParticipant?.permission = .readOnly + let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await self.userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - try await self.userDatabase.userRead { db in - try #expect(Reminder.all.fetchCount(db) == 0) - } - 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" - ) - ] + try await self.userDatabase.userRead { db in + try #expect(Reminder.all.fetchCount(db) == 0) + } + 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" + ) + ] + ) ) - ) - """ + """ + } } - } - // Edit a record while locally we think we have permission, but CloudKit has newer permissions - // that are read only. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func editRecordWhenLocalHasPermissionsButCloudKitDoesNot() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" + // Edit a record while locally we think we have permission, but CloudKit has newer permissions + // that are read only. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editRecordWhenLocalHasPermissionsButCloudKitDoesNot() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) ) - ) - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + 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 + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) ) - ) - share.publicPermission = .readWrite - share.currentUserParticipant?.permission = .readWrite - - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share + 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 ) ) + share.publicPermission = .readWrite + share.currentUserParticipant?.permission = .readWrite - let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare - freshShare.publicPermission = .readOnly - freshShare.currentUserParticipant?.permission = .readOnly - let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - try await self.userDatabase.userWrite { db in - try RemindersList.find(1).update { $0.title = "Business" }.execute(db) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + freshShare.publicPermission = .readOnly + freshShare.currentUserParticipant?.permission = .readOnly + let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await self.userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Business" }.execute(db) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - try await self.userDatabase.userRead { db in - try #expect(RemindersList.find(1).fetchOne(db) == RemindersList(id: 1, title: "Personal")) - } - 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" - ) - ] + try await self.userDatabase.userRead { db in + try #expect(RemindersList.find(1).fetchOne(db) == RemindersList(id: 1, title: "Personal")) + } + 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" + ) + ] + ) ) - ) - """ + """ + } } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index 795cadd1..ae47e15a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -1,272 +1,308 @@ -import CloudKit -import CustomDump -import Foundation -import GRDB -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTestingCustomDump -import Testing - -extension BaseCloudKitTests { - @MainActor - final class SharingTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func shareNonRootRecord() async throws { - let reminder = Reminder(id: 1, title: "Groceries", remindersListID: 1) - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - reminder +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import GRDB + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class SharingTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareNonRootRecord() async throws { + let reminder = Reminder(id: 1, title: "Groceries", remindersListID: 1) + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + reminder + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let error = await #expect(throws: (any Error).self) { - _ = try await self.syncEngine.share(record: reminder, configure: { _ in }) - } - assertInlineSnapshot(of: error?.localizedDescription, as: .customDump) { - """ - "The record could not be shared." - """ - } - assertInlineSnapshot(of: error, as: .customDump) { - """ - SyncEngine.SharingError( - recordTableName: "reminders", - recordPrimaryKey: "1", - reason: .recordNotRoot( - [ - [0]: ForeignKey( - table: "remindersLists", - from: "remindersListID", - to: "id", - onUpdate: .cascade, - onDelete: .cascade, - notnull: true - ) - ] - ), - debugDescription: "Only root records are shareable, but parent record(s) detected via foreign key(s)." - ) - """ + let error = await #expect(throws: (any Error).self) { + _ = try await self.syncEngine.share(record: reminder, configure: { _ in }) + } + assertInlineSnapshot(of: error?.localizedDescription, as: .customDump) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SharingError( + recordTableName: "reminders", + recordPrimaryKey: "1", + reason: .recordNotRoot( + [ + [0]: ForeignKey( + table: "remindersLists", + from: "remindersListID", + to: "id", + onUpdate: .cascade, + onDelete: .cascade, + notnull: true + ) + ] + ), + debugDescription: "Only root records are shareable, but parent record(s) detected via foreign key(s)." + ) + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func shareUnrecognizedTable() async throws { - let error = await #expect(throws: (any Error).self) { - _ = try await self.syncEngine.share( - record: UnsyncedModel(id: 42), - configure: { _ in } - ) - } - assertInlineSnapshot( - of: (error as? any LocalizedError)?.localizedDescription, - as: .customDump - ) { - """ - "The record could not be shared." - """ - } - assertInlineSnapshot(of: error, as: .customDump) { - #""" - SyncEngine.SharingError( - recordTableName: "unsyncedModels", - recordPrimaryKey: "42", - reason: .recordTableNotSynchronized, - debugDescription: "Table is not shareable: table type not passed to \'tables\' parameter of \'SyncEngine.init\'." - ) - """# + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareUnrecognizedTable() async throws { + let error = await #expect(throws: (any Error).self) { + _ = try await self.syncEngine.share( + record: UnsyncedModel(id: 42), + configure: { _ in } + ) + } + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + #""" + SyncEngine.SharingError( + recordTableName: "unsyncedModels", + recordPrimaryKey: "42", + reason: .recordTableNotSynchronized, + debugDescription: "Table is not shareable: table type not passed to \'tables\' parameter of \'SyncEngine.init\'." + ) + """# + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func sharePrivateTable() async throws { - let error = await #expect(throws: (any Error).self) { - _ = try await self.syncEngine.share( - record: RemindersListPrivate(id: 1, remindersListID: 1), - configure: { _ in } - ) - } - assertInlineSnapshot( - of: (error as? any LocalizedError)?.localizedDescription, - as: .customDump - ) { - """ - "The record could not be shared." - """ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sharePrivateTable() async throws { + let error = await #expect(throws: (any Error).self) { + _ = try await self.syncEngine.share( + record: RemindersListPrivate(id: 1, remindersListID: 1), + configure: { _ in } + ) + } + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SharingError( + recordTableName: "remindersListPrivates", + recordPrimaryKey: "1", + reason: .recordNotRoot( + [ + [0]: ForeignKey( + table: "remindersLists", + from: "remindersListID", + to: "id", + onUpdate: .noAction, + onDelete: .cascade, + notnull: true + ) + ] + ), + debugDescription: "Only root records are shareable, but parent record(s) detected via foreign key(s)." + ) + """ + } } - assertInlineSnapshot(of: error, as: .customDump) { - """ - SyncEngine.SharingError( - recordTableName: "remindersListPrivates", - recordPrimaryKey: "1", - reason: .recordNotRoot( - [ - [0]: ForeignKey( - table: "remindersLists", - from: "remindersListID", - to: "id", - onUpdate: .noAction, - onDelete: .cascade, - notnull: true - ) - ] - ), - debugDescription: "Only root records are shareable, but parent record(s) detected via foreign key(s)." - ) - """ + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareRecordBeforeSync() async throws { + let error = await #expect(throws: (any Error).self) { + _ = try await self.syncEngine.share( + record: RemindersList(id: 1), + configure: { _ in } + ) + } + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { + """ + "The record could not be shared." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SharingError( + recordTableName: "remindersLists", + recordPrimaryKey: "1", + reason: .recordMetadataNotFound, + debugDescription: "No sync metadata found for record. Has the record been saved to the database?" + ) + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func shareRecordBeforeSync() async throws { - let error = await #expect(throws: (any Error).self) { - _ = try await self.syncEngine.share( - record: RemindersList(id: 1), - configure: { _ in } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createRecordInExternallySharedRecord() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" ) - } - assertInlineSnapshot( - of: (error as? any LocalizedError)?.localizedDescription, - as: .customDump - ) { - """ - "The record could not be shared." - """ - } - assertInlineSnapshot(of: error, as: .customDump) { - """ - SyncEngine.SharingError( - recordTableName: "remindersLists", - recordPrimaryKey: "1", - reason: .recordMetadataNotFound, - debugDescription: "No sync metadata found for record. Has the record been saved to the database?" + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) ) - """ - } - } + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func createRecordInExternallySharedRecord() async throws { - let externalZoneID = CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - let externalZone = CKRecordZone(zoneID: externalZoneID) - - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) - ) - remindersListRecord.setValue(1, forKey: "id", at: now) - remindersListRecord.setValue(false, forKey: "isCompleted", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() - - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { - try await userDatabase.userWrite { db in - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: 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" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + isCompleted: 0, + title: "Personal" + ) + ] + ) + ) + """ + } } - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: 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" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - isCompleted: 0, - title: "Personal" - ) - ] + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareDelieveredBeforeRecord() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" ) ) - """ - } - } + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func shareDelieveredBeforeRecord() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) ) - ) - 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(false, forKey: "isCompleted", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - let share = CKShare( - rootRecord: remindersListRecord, - shareID: CKRecord.ID( - recordName: "share-\(remindersListRecord.recordID.recordName)", - zoneID: externalZone.zoneID + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: externalZone.zoneID + ) ) - ) - - _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) - - let newShare = try syncEngine.shared.database.record(for: share.recordID) - let newRemindersListRecord = try syncEngine.shared.database.record( - for: remindersListRecord.recordID - ) - try await syncEngine.modifyRecords(scope: .shared, saving: [newShare]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [newRemindersListRecord]).notify() - - 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", + + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + + let newShare = try syncEngine.shared.database.record(for: share.recordID) + let newRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + try await syncEngine.modifyRecords(scope: .shared, saving: [newShare]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [newRemindersListRecord]) + .notify() + + 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, + isCompleted: 0, + title: "Personal" + ) + ] + ) + ) + """ + } + + 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: nil + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)) ), - [1]: CKRecord( + _lastKnownServerRecordAllFields: CKRecord( recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, @@ -274,632 +310,600 @@ extension BaseCloudKitTests { id: 1, isCompleted: 0, title: "Personal" - ) - ] - ) - ) - """ - } - - 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) - ) - ] - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func shareeCreatesMultipleChildModels() async throws { - let externalZoneID = CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - let externalZone = CKRecordZone(zoneID: externalZoneID) - - let modelARecord = CKRecord( - recordType: ModelA.tableName, - recordID: ModelA.recordID(for: 1, zoneID: externalZoneID) - ) - modelARecord.setValue(1, forKey: "id", at: now) - modelARecord.setValue(0, forKey: "count", at: now) - - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [modelARecord]).notify() - - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { - try await userDatabase.userWrite { db in - try db.seed { - ModelB(id: 1, modelAID: 1) - ModelC(id: 1, modelBID: 1) - } - } - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:modelAs/external.zone/external.owner), - recordType: "modelAs", - parent: nil, - share: nil, - count: 0, - id: 1 ), - [1]: CKRecord( - recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), - recordType: "modelBs", - parent: CKReference(recordID: CKRecord.ID(1:modelAs/external.zone/external.owner)), - share: nil, - id: 1, - isOn: 0, - modelAID: 1 + share: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil ), - [2]: CKRecord( - recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), - recordType: "modelCs", - parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), - share: nil, - id: 1, - modelBID: 1, - title: "" - ) - ] - ) - ) - """ + _isDeleted: false, + isShared: true, + userModificationDate: Date(1970-01-01T00:00:00.000Z) + ) + ] + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteRecordInExternallySharedRecord() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareeCreatesMultipleChildModels() async throws { + let externalZoneID = 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 reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue(false, forKey: "isCompleted", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - - try await syncEngine.modifyRecords( - scope: .shared, - saving: [remindersListRecord, reminderRecord] - ).notify() - - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).delete().execute(db) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let modelARecord = CKRecord( + recordType: ModelA.tableName, + recordID: ModelA.recordID(for: 1, zoneID: externalZoneID) + ) + modelARecord.setValue(1, forKey: "id", at: now) + modelARecord.setValue(0, forKey: "count", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [modelARecord]).notify() + + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + ModelB(id: 1, modelAID: 1) + ModelC(id: 1, modelBID: 1) + } + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:modelAs/external.zone/external.owner), + recordType: "modelAs", + parent: nil, + share: nil, + count: 0, + id: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), + recordType: "modelBs", + parent: CKReference(recordID: CKRecord.ID(1:modelAs/external.zone/external.owner)), + share: nil, + id: 1, + isOn: 0, + modelAID: 1 + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:modelCs/external.zone/external.owner), + recordType: "modelCs", + parent: CKReference(recordID: CKRecord.ID(1:modelBs/external.zone/external.owner)), + share: nil, + id: 1, + modelBID: 1, + title: "" + ) + ] + ) + ) + """ } } - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRecordInExternallySharedRecord() 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 reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue(false, forKey: "isCompleted", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + + try await syncEngine.modifyRecords( + scope: .shared, + saving: [remindersListRecord, reminderRecord] + ).notify() + + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).delete().execute(db) + } + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func share() async throws { - let remindersList = RemindersList(id: 1, title: "Personal") - try await userDatabase.userWrite { db in - try db.seed { - remindersList + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ) + ) + """ } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let sharedRecord = try await syncEngine.share(record: remindersList, configure: { _ in }) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func share() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - 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) - } + let sharedRecord = try await syncEngine.share(record: remindersList, configure: { _ in }) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await userDatabase.read { db in + let metadata = try #require( + try SyncMetadata + .where { $0.recordPrimaryKey.eq("1") } + .fetchOne(db) ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func unshareNonSharedRecord() async throws { - let remindersList = RemindersList(id: 1, title: "Personal") - try await userDatabase.userWrite { db in - try db.seed { - remindersList + #expect(metadata.share?.recordID == sharedRecord.share.recordID) } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await withKnownIssue { - try await syncEngine.unshare(record: remindersList) - } matching: { issue in - issue.description == """ - Issue recorded: No share found associated with record. + assertInlineSnapshot(of: container, as: .customDump) { """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func shareUnshareShareAgain() async throws { - let remindersList = RemindersList(id: 1, title: "Personal") - try await userDatabase.userWrite { db in - try db.seed { - remindersList + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func unshareNonSharedRecord() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withKnownIssue { + try await syncEngine.unshare(record: remindersList) + } matching: { issue in + issue.description == """ + Issue recorded: No share found associated with record. + """ } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func shareUnshareShareAgain() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await syncEngine.unshare(record: remindersList) + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) - 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: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await syncEngine.unshare(record: remindersList) + + 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: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } + """ + } - let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } + } - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShare() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func acceptShare() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) ) - ) - 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 + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) ) - ) - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) ) - ) - 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)" - ) + 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)" + ) + } + + 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" + ) + ] + ) + ) + """ + } } - 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" - ) - ] + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShareCreateReminder() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" ) ) - """ - } - } + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func acceptShareCreateReminder() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) ) - ) - 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 + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) ) - ) - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) ) - ) - try await userDatabase.userWrite { db in - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + 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") - } + 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") + } - 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" - ) - ] + 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 func deleteRootSharedRecord_CurrentUserOwnsRecord() async throws { - let remindersList = RemindersList(id: 1, title: "Personal") - try await userDatabase.userWrite { db in - try db.seed { - remindersList + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRootSharedRecord_CurrentUserOwnsRecord() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try #expect(RemindersList.all.fetchCount(db) == 0) + } - try await userDatabase.userWrite { db in - try #expect(RemindersList.all.fetchCount(db) == 0) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + /// Deleting a root shared record that is not owned by current user should only delete + /// the CKShare but not the actual records. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteRootSharedRecord_CurrentUserNotOwner() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" ) ) - """ - } - } + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - /// Deleting a root shared record that is not owned by current user should only delete - /// the CKShare but not the actual records. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteRootSharedRecord_CurrentUserNotOwner() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) ) - ) - 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 + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) ) - ) - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) ) - ) - try await userDatabase.userWrite { db in - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + 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) - } + try await userDatabase.read { db in + let share = + try SyncMetadata + .where { $0.recordName.eq(remindersListRecord.recordID.recordName) } + .select(\.share) + .fetchOne(db) + #expect(share == .none) + } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: 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" - ), - [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" - ) - ] + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: 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" + ), + [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" + ) + ] + ) ) - ) - """ + """ + } } } } -} // TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index ac9c3825..9dcbf44b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -1,449 +1,455 @@ -import CloudKit -import DependenciesTestSupport -import InlineSnapshotTesting -import OrderedCollections -import SQLiteData -import SnapshotTesting -import SnapshotTestingCustomDump -import Testing -import os - -extension BaseCloudKitTests { - @Suite - struct SyncEngineLifecycleTests { - @MainActor +#if canImport(CloudKit) + import CloudKit + import DependenciesTestSupport + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTesting + import SnapshotTestingCustomDump + import Testing + import os + + extension BaseCloudKitTests { @Suite - final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked Sendable - { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func stopAndReStart() async throws { - syncEngine.stop() - - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) + struct SyncEngineLifecycleTests { + @MainActor + @Suite + final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked + Sendable + { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func stopAndReStart() async throws { + syncEngine.stop() + + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } - } - try await Task.sleep(for: .seconds(0.5)) + 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) + 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)) - } + let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) + #expect(reminderMetadata.lastKnownServerRecord == nil) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } + """ + } - try await syncEngine.start() - 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: "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 syncEngine.start() + 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: "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: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func writeStopDeleteStart() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func writeStopDeleteStart() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - syncEngine.stop() + syncEngine.stop() - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await Task.sleep(for: .seconds(0.5)) - try await Task.sleep(for: .seconds(0.5)) - - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - syncEngine.stop() + syncEngine.stop() - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - try await userDatabase.userWrite { db in - try RemindersList.find(1).update { $0.title += "!" }.execute(db) + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + 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 userDatabase.read { db in + try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) + try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") + } + + try await syncEngine.start() + 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: [] + ) + ) + """ + } + + try await userDatabase.read { db in + try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) + } } - try await Task.sleep(for: .seconds(0.5)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) - try await userDatabase.read { db in - try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) - try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } } + try await Task.sleep(for: .seconds(0.5)) try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + 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" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, share: nil, id: 1, - title: "Personal!" + isCompleted: 0, + title: "Personal" ) ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] ) ) """ } - - try await userDatabase.read { db in - try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) - } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { - let externalZoneID = CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - let externalZone = CKRecordZone(zoneID: externalZoneID) - - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) - ) - remindersListRecord.setValue(1, forKey: "id", at: now) - remindersListRecord.setValue(false, forKey: "isCompleted", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() - - syncEngine.stop() - - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func externalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() + async throws + { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + try await userDatabase.userWrite { db in - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } + try RemindersList.find(1).delete().execute(db) } - } - try await Task.sleep(for: .seconds(0.5)) - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: 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" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - isCompleted: 0, - title: "Personal" - ) - ] + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func externalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() - async throws - { - let externalZoneID = CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - let externalZone = CKRecordZone(zoneID: externalZoneID) - - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) - ) - remindersListRecord.setValue(1, forKey: "id", at: now) - remindersListRecord.setValue(false, forKey: "isCompleted", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() - - syncEngine.stop() - - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { remindersList } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } - } + """ + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { - let remindersList = RemindersList(id: 1, title: "Personal") - try await userDatabase.userWrite { db in - try db.seed { remindersList } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } + syncEngine.stop() - syncEngine.stop() + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } + try await Task.sleep(for: .seconds(0.5)) + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await Task.sleep(for: .seconds(0.5)) - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ + """ + } } } - } - @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) - } + @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) + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func writeAndThenStart() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func writeAndThenStart() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } } - } - try await userDatabase.userRead { db in - let remindersListMetadata = try #require(try RemindersList.metadata(for: 1).fetchOne(db)) - #expect(remindersListMetadata.lastKnownServerRecord == nil) + 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)) - } + let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) + #expect(reminderMetadata.lastKnownServerRecord == nil) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) ) - ) - """ - } + """ + } - try await syncEngine.start() - await signIn() - try await syncEngine.processPendingDatabaseChanges(scope: .private) - 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: "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 syncEngine.start() + await signIn() + try await syncEngine.processPendingDatabaseChanges(scope: .private) + 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: "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: [] + ) ) - ) - """ + """ + } } } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift index 55d97e1c..cb278333 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift @@ -1,51 +1,28 @@ -import CloudKit -import CustomDump -import DependenciesTestSupport -import Foundation -import InlineSnapshotTesting -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import CustomDump + import DependenciesTestSupport + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - final class SyncEngineTests { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func inMemory() throws { - #expect(URL(string: "")?.isInMemory == nil) - #expect(URL(string: ":memory:")?.isInMemory == true) - #expect(URL(string: ":memory:?cache=shared")?.isInMemory == true) - #expect(URL(string: "file::memory:")?.isInMemory == true) - #expect(URL(string: "file:memdb1?mode=memory&cache=shared")?.isInMemory == true) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func inMemoryUserDatabase() async throws { - let syncEngine = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "test", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ), - userDatabase: UserDatabase(database: DatabaseQueue()), - tables: [] - ) - - try await syncEngine.userDatabase.read { db in - try SQLQueryExpression( - """ - SELECT 1 FROM "sqlitedata_icloud_metadata" - """ - ) - .execute(db) + extension BaseCloudKitTests { + @MainActor + final class SyncEngineTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func inMemory() throws { + #expect(URL(string: "")?.isInMemory == nil) + #expect(URL(string: ":memory:")?.isInMemory == true) + #expect(URL(string: ":memory:?cache=shared")?.isInMemory == true) + #expect(URL(string: "file::memory:")?.isInMemory == true) + #expect(URL(string: "file:memdb1?mode=memory&cache=shared")?.isInMemory == true) } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test(.dependency(\.context, .live)) - func inMemoryUserDatabase_LiveContext() async throws { - let error = await #expect(throws: (any Error).self) { - try await SyncEngine( + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func inMemoryUserDatabase() async throws { + let syncEngine = try await SyncEngine( container: MockCloudContainer( containerIdentifier: "test", privateCloudDatabase: MockCloudDatabase(databaseScope: .private), @@ -54,50 +31,75 @@ extension BaseCloudKitTests { userDatabase: UserDatabase(database: DatabaseQueue()), tables: [] ) + + try await syncEngine.userDatabase.read { db in + try SQLQueryExpression( + """ + SELECT 1 FROM "sqlitedata_icloud_metadata" + """ + ) + .execute(db) + } } - assertInlineSnapshot(of: error, as: .customDump) { - """ - InMemoryDatabase() - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func metadatabaseMismatch() async throws { - let error = await #expect(throws: (any Error).self) { - var configuration = Configuration() - configuration.prepareDatabase { db in - try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree") + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.dependency(\.context, .live)) + func inMemoryUserDatabase_LiveContext() async throws { + let error = await #expect(throws: (any Error).self) { + try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "test", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: DatabaseQueue()), + tables: [] + ) + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + InMemoryDatabase() + """ } - let database = try DatabasePool( - path: "/tmp/db.sqlite", - configuration: configuration - ) - _ = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "iCloud.co.point-free", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ), - userDatabase: UserDatabase(database: database), - tables: [] - ) } - assertInlineSnapshot(of: error, as: .customDump) { - #""" - SyncEngine.SchemaError( - reason: .metadatabaseMismatch( - attachedPath: "/private/tmp/.db.metadata-iCloud.co.pointfree.sqlite", - syncEngineConfiguredPath: "/tmp/.db.metadata-iCloud.co.point-free.sqlite" - ), - debugDescription: "Metadatabase attached in \'prepareDatabase\' does not match metadatabase prepared in \'SyncEngine.init\'. Are different CloudKit container identifiers being provided?" - ) - """# + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func metadatabaseMismatch() async throws { + let error = await #expect(throws: (any Error).self) { + var configuration = Configuration() + configuration.prepareDatabase { db in + try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree") + } + let database = try DatabasePool( + path: "/tmp/db.sqlite", + configuration: configuration + ) + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "iCloud.co.point-free", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [] + ) + } + assertInlineSnapshot(of: error, as: .customDump) { + #""" + SyncEngine.SchemaError( + reason: .metadatabaseMismatch( + attachedPath: "/private/tmp/.db.metadata-iCloud.co.pointfree.sqlite", + syncEngineConfiguredPath: "/tmp/.db.metadata-iCloud.co.point-free.sqlite" + ), + debugDescription: "Metadatabase attached in \'prepareDatabase\' does not match metadatabase prepared in \'SyncEngine.init\'. Are different CloudKit container identifiers being provided?" + ) + """# + } } } } -} -private func databaseWithForeignKeys() throws -> any DatabaseWriter { - try DatabaseQueue() -} + private func databaseWithForeignKeys() throws -> any DatabaseWriter { + try DatabaseQueue() + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift index beede6e4..6f3b5f62 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift @@ -1,326 +1,328 @@ -import CloudKit -import CustomDump -import Foundation -import InlineSnapshotTesting -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @Table("invalid:table") - struct InvalidTable { - let id: UUID - } - - @MainActor - struct SyncEngineValidationTests { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func tableNameValidation() async throws { - let error = try #require( - await #expect(throws: (any Error).self) { - let database = try DatabaseQueue() - _ = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "deadbeef", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ), - userDatabase: UserDatabase(database: database), - tables: [InvalidTable.self] - ) - } - ) - assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { - """ - "Could not synchronize data with iCloud." - """ - } - assertInlineSnapshot(of: error, as: .customDump) { - #""" - SyncEngine.SchemaError( - reason: .invalidTableName("invalid:table"), - debugDescription: "Table name contains invalid character \':\'" - ) - """# - } + extension BaseCloudKitTests { + @Table("invalid:table") + struct InvalidTable { + let id: UUID } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func foreignKeyActionValidation_NoAction() async throws { - let error = try #require( - await #expect(throws: (any Error).self) { - let database = try DatabaseQueue() - try await database.write { db in - try #sql( - """ - CREATE TABLE "parents" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL - ) STRICT - """ + @MainActor + struct SyncEngineValidationTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tableNameValidation() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [InvalidTable.self] ) - .execute(db) - try #sql( - """ - CREATE TABLE "childs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "parentID" INTEGER REFERENCES "parents"("id") ON DELETE NO ACTION - ) STRICT - """ - ) - .execute(db) } - _ = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "deadbeef", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ), - userDatabase: UserDatabase(database: database), - tables: [Child.self, Parent.self] + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + #""" + SyncEngine.SchemaError( + reason: .invalidTableName("invalid:table"), + debugDescription: "Table name contains invalid character \':\'" ) + """# } - ) - assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { - """ - "Could not synchronize data with iCloud." - """ } - assertInlineSnapshot(of: error, as: .customDump) { - """ - SyncEngine.SchemaError( - reason: .invalidForeignKeyAction( - ForeignKey( - table: "parents", - from: "parentID", - to: "id", - onUpdate: .noAction, - onDelete: .noAction, - notnull: false - ) - ), - debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# - ) - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func foreignKeyActionValidation_Restrict() async throws { - let error = try #require( - await #expect(throws: (any Error).self) { - let database = try DatabaseQueue() - try await database.write { db in - try #sql( - """ - CREATE TABLE "parents" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL - ) STRICT - """ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeyActionValidation_NoAction() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "childs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE NO ACTION + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [Child.self, Parent.self] ) - .execute(db) - try #sql( - """ - CREATE TABLE "childs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "parentID" INTEGER REFERENCES "parents"("id") ON DELETE RESTRICT - ) STRICT - """ - ) - .execute(db) } - _ = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "deadbeef", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .invalidForeignKeyAction( + ForeignKey( + table: "parents", + from: "parentID", + to: "id", + onUpdate: .noAction, + onDelete: .noAction, + notnull: false + ) ), - userDatabase: UserDatabase(database: database), - tables: [Parent.self, Child.self] + debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# ) + """ } - ) - assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { - """ - "Could not synchronize data with iCloud." - """ - } - assertInlineSnapshot(of: error, as: .customDump) { - """ - SyncEngine.SchemaError( - reason: .invalidForeignKeyAction( - ForeignKey( - table: "parents", - from: "parentID", - to: "id", - onUpdate: .noAction, - onDelete: .restrict, - notnull: false - ) - ), - debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# - ) - """ } - } - @Table struct Child: Identifiable { - let id: Int - var parentID: Parent.ID - } - @Table struct Parent: Identifiable { - let id: Int - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func foreignKeyPointsToOtherSynchronizedTable() async throws { - let error = try #require( - await #expect(throws: (any Error).self) { - let database = try DatabaseQueue() - try await database.write { db in - try #sql( - """ - CREATE TABLE "parents" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL - ) STRICT - """ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeyActionValidation_Restrict() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "childs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE RESTRICT + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [Parent.self, Child.self] ) - .execute(db) - try #sql( - """ - CREATE TABLE "childs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "parentID" INTEGER REFERENCES "parents"("id") ON DELETE CASCADE - ) STRICT - """ - ) - .execute(db) } - _ = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "deadbeef", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .invalidForeignKeyAction( + ForeignKey( + table: "parents", + from: "parentID", + to: "id", + onUpdate: .noAction, + onDelete: .restrict, + notnull: false + ) ), - userDatabase: UserDatabase(database: database), - tables: [Child.self] + debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# ) + """ } - ) - assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { - """ - "Could not synchronize data with iCloud." - """ } - assertInlineSnapshot(of: error, as: .customDump) { - """ - SyncEngine.SchemaError( - reason: .invalidForeignKey( - ForeignKey( - table: "parents", - from: "parentID", - to: "id", - onUpdate: .noAction, - onDelete: .cascade, - notnull: false + + @Table struct Child: Identifiable { + let id: Int + var parentID: Parent.ID + } + @Table struct Parent: Identifiable { + let id: Int + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func foreignKeyPointsToOtherSynchronizedTable() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "childs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "parents"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [Child.self] ) - ), - debugDescription: #"Foreign key "childs"."parentID" references table "parents" that is not synchronized. Update 'SyncEngine.init' to synchronize "parents". "# + } ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func doNotValidateTriggersOnNonSyncedTables() async throws { - let database = try DatabaseQueue( - path: URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite").path() - ) - try await database.write { db in - try #sql( + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { """ - CREATE TABLE "remindersLists" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL DEFAULT '' - ) STRICT + "Could not synchronize data with iCloud." """ - ) - .execute(db) - try #sql( + } + assertInlineSnapshot(of: error, as: .customDump) { """ - CREATE TRIGGER "non_temporary_trigger" - AFTER UPDATE ON "remindersLists" - FOR EACH ROW BEGIN - SELECT 1; - END + SyncEngine.SchemaError( + reason: .invalidForeignKey( + ForeignKey( + table: "parents", + from: "parentID", + to: "id", + onUpdate: .noAction, + onDelete: .cascade, + notnull: false + ) + ), + debugDescription: #"Foreign key "childs"."parentID" references table "parents" that is not synchronized. Update 'SyncEngine.init' to synchronize "parents". "# + ) """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func doNotValidateTriggersOnNonSyncedTables() async throws { + let database = try DatabaseQueue( + path: URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite").path() ) - .execute(db) - try #sql( - """ - CREATE TEMPORARY TRIGGER "temporary_trigger" - AFTER UPDATE ON "remindersLists" - FOR EACH ROW BEGIN - SELECT 1; - END - """ + try await database.write { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TRIGGER "non_temporary_trigger" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + SELECT 1; + END + """ + ) + .execute(db) + try #sql( + """ + CREATE TEMPORARY TRIGGER "temporary_trigger" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + SELECT 1; + END + """ + ) + .execute(db) + } + let _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [] ) - .execute(db) } - let _ = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "deadbeef", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ), - userDatabase: UserDatabase(database: database), - tables: [] - ) - } - @Table struct ModelWithUniqueColumn { - let id: Int - let uniqueValue: Int - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func uniquenessConstraint() async throws { - let error = try #require( - await #expect(throws: (any Error).self) { - let database = try DatabaseQueue() - try await database.write { db in - try #sql( - """ - CREATE TABLE "modelWithUniqueColumns" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "uniqueValue" INTEGER NOT NULL, - UNIQUE("uniqueValue") - ) STRICT - """ + @Table struct ModelWithUniqueColumn { + let id: Int + let uniqueValue: Int + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func uniquenessConstraint() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "modelWithUniqueColumns" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uniqueValue" INTEGER NOT NULL, + UNIQUE("uniqueValue") + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [ModelWithUniqueColumn.self] ) - .execute(db) } - _ = try await SyncEngine( - container: MockCloudContainer( - containerIdentifier: "deadbeef", - privateCloudDatabase: MockCloudDatabase(databaseScope: .private), - sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) - ), - userDatabase: UserDatabase(database: database), - tables: [ModelWithUniqueColumn.self] + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .uniquenessConstraint, + debugDescription: "Uniqueness constraints are not supported for synchronized tables." ) + """ } - ) - assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { - """ - "Could not synchronize data with iCloud." - """ - } - assertInlineSnapshot(of: error, as: .customDump) { - """ - SyncEngine.SchemaError( - reason: .uniquenessConstraint, - debugDescription: "Uniqueness constraints are not supported for synchronized tables." - ) - """ } } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index 6b0be003..ded0c457 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -1,1255 +1,1257 @@ -import CloudKit -import CustomDump -import InlineSnapshotTesting -import SQLiteData -import SnapshotTestingCustomDump -import Testing +#if canImport(CloudKit) + import CloudKit + import CustomDump + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing -extension BaseCloudKitTests { - @MainActor - final class TriggerTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func triggers() async throws { - let triggersAfterSetUp = try await userDatabase.userWrite { db in - try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) - } - #if DEBUG - assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { - #""" - [ - [0]: """ - CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" - AFTER UPDATE OF "_isDeleted" ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN ((NOT ("old"."_isDeleted") AND "new"."_isDeleted") AND NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges())) BEGIN - SELECT sqlitedata_icloud_didDelete("new"."recordName", coalesce("new"."lastKnownServerRecord", ( - WITH "ancestorMetadatas" AS ( - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + extension BaseCloudKitTests { + @MainActor + final class TriggerTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func triggers() async throws { + let triggersAfterSetUp = try await userDatabase.userWrite { db in + try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) + } + #if DEBUG + assertInlineSnapshot(of: triggersAfterSetUp, as: .customDump) { + #""" + [ + [0]: """ + CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" + AFTER UPDATE OF "_isDeleted" ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN ((NOT ("old"."_isDeleted") AND "new"."_isDeleted") AND NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges())) BEGIN + SELECT sqlitedata_icloud_didDelete("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) + )), "new"."share"); + END + """, + [1]: """ + CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" + AFTER INSERT ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) + )), "new"."share"); + END + """, + [2]: """ + CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" + AFTER UPDATE ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN (("old"."_isDeleted" = "new"."_isDeleted") AND NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges())) BEGIN + SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + WITH "ancestorMetadatas" AS ( + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + UNION ALL + SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + FROM "sqlitedata_icloud_metadata" + JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + ) + SELECT "ancestorMetadatas"."lastKnownServerRecord" + FROM "ancestorMetadatas" + WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) + )), "new"."share"); + END + """, + [3]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_sync_engine" + AFTER DELETE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [4]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_user" + AFTER DELETE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) UNION ALL - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" - JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT "ancestorMetadatas"."lastKnownServerRecord" - FROM "ancestorMetadatas" - WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - )), "new"."share"); - END - """, - [1]: """ - CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" - AFTER INSERT ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( - WITH "ancestorMetadatas" AS ( - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [5]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_sync_engine" + AFTER DELETE ON "childWithOnDeleteSetNulls" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [6]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_user" + AFTER DELETE ON "childWithOnDeleteSetNulls" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) UNION ALL - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" - JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT "ancestorMetadatas"."lastKnownServerRecord" - FROM "ancestorMetadatas" - WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - )), "new"."share"); - END - """, - [2]: """ - CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" - AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN (("old"."_isDeleted" = "new"."_isDeleted") AND NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges())) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( - WITH "ancestorMetadatas" AS ( - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" - FROM "sqlitedata_icloud_metadata" - WHERE ("sqlitedata_icloud_metadata"."recordName" = "new"."recordName") + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [7]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_sync_engine" + AFTER DELETE ON "modelAs" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [8]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_user" + AFTER DELETE ON "modelAs" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) UNION ALL - SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" - JOIN "ancestorMetadatas" ON ("sqlitedata_icloud_metadata"."recordName" IS "ancestorMetadatas"."parentRecordName") + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT "ancestorMetadatas"."lastKnownServerRecord" - FROM "ancestorMetadatas" - WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - )), "new"."share"); - END - """, - [3]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_sync_engine" - AFTER DELETE ON "childWithOnDeleteSetDefaults" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); - END - """, - [4]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_user" - AFTER DELETE ON "childWithOnDeleteSetDefaults" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); - END - """, - [5]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_sync_engine" - AFTER DELETE ON "childWithOnDeleteSetNulls" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); - END - """, - [6]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_user" - AFTER DELETE ON "childWithOnDeleteSetNulls" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); - END - """, - [7]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_sync_engine" - AFTER DELETE ON "modelAs" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); - END - """, - [8]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_user" - AFTER DELETE ON "modelAs" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); - END - """, - [9]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_sync_engine" - AFTER DELETE ON "modelBs" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); - END - """, - [10]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_user" - AFTER DELETE ON "modelBs" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); - END - """, - [11]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_sync_engine" - AFTER DELETE ON "modelCs" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); - END - """, - [12]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_user" - AFTER DELETE ON "modelCs" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); - END - """, - [13]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_sync_engine" - AFTER DELETE ON "parents" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); - END - """, - [14]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_user" - AFTER DELETE ON "parents" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); - END - """, - [15]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_sync_engine" - AFTER DELETE ON "reminderTags" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); - END - """, - [16]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_user" - AFTER DELETE ON "reminderTags" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); - END - """, - [17]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_sync_engine" - AFTER DELETE ON "remindersListAssets" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); - END - """, - [18]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_user" - AFTER DELETE ON "remindersListAssets" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); - END - """, - [19]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_sync_engine" - AFTER DELETE ON "remindersListPrivates" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); - END - """, - [20]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_user" - AFTER DELETE ON "remindersListPrivates" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); - END - """, - [21]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_sync_engine" - AFTER DELETE ON "remindersLists" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); - END - """, - [22]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_user" - AFTER DELETE ON "remindersLists" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); - END - """, - [23]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_sync_engine" - AFTER DELETE ON "reminders" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); - END - """, - [24]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_user" - AFTER DELETE ON "reminders" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); - END - """, - [25]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_sync_engine" - AFTER DELETE ON "tags" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN - DELETE FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); - END - """, - [26]: """ - CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_user" - AFTER DELETE ON "tags" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); - END - """, - [27]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" - AFTER INSERT ON "childWithOnDeleteSetDefaults" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [28]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" - AFTER INSERT ON "childWithOnDeleteSetNulls" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [29]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" - AFTER INSERT ON "modelAs" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelAs', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [30]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" - AFTER INSERT ON "modelBs" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [31]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" - AFTER INSERT ON "modelCs" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [32]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" - AFTER INSERT ON "parents" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'parents', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [33]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" - AFTER INSERT ON "reminderTags" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'reminderTags', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [34]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" - AFTER INSERT ON "reminders" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [35]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" - AFTER INSERT ON "remindersListAssets" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [36]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" - AFTER INSERT ON "remindersListPrivates" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [37]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" - AFTER INSERT ON "remindersLists" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersLists', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [38]: """ - CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" - AFTER INSERT ON "tags" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."title", 'tags', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [39]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetDefaults" - AFTER UPDATE OF "id" ON "childWithOnDeleteSetDefaults" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); - END - """, - [40]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetNulls" - AFTER UPDATE OF "id" ON "childWithOnDeleteSetNulls" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); - END - """, - [41]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelAs" - AFTER UPDATE OF "id" ON "modelAs" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); - END - """, - [42]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelBs" - AFTER UPDATE OF "id" ON "modelBs" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); - END - """, - [43]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelCs" - AFTER UPDATE OF "id" ON "modelCs" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); - END - """, - [44]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_parents" - AFTER UPDATE OF "id" ON "parents" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); - END - """, - [45]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminderTags" - AFTER UPDATE OF "id" ON "reminderTags" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); - END - """, - [46]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminders" - AFTER UPDATE OF "id" ON "reminders" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); - END - """, - [47]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListAssets" - AFTER UPDATE OF "id" ON "remindersListAssets" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); - END - """, - [48]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListPrivates" - AFTER UPDATE OF "id" ON "remindersListPrivates" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); - END - """, - [49]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersLists" - AFTER UPDATE OF "id" ON "remindersLists" - FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); - END - """, - [50]: """ - CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_tags" - AFTER UPDATE OF "title" ON "tags" - FOR EACH ROW WHEN ("old"."title" <> "new"."title") BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - UPDATE "sqlitedata_icloud_metadata" - SET "_isDeleted" = 1 - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); - END - """, - [51]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" - AFTER UPDATE ON "childWithOnDeleteSetDefaults" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [52]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" - AFTER UPDATE ON "childWithOnDeleteSetNulls" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [53]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" - AFTER UPDATE ON "modelAs" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelAs', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [54]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" - AFTER UPDATE ON "modelBs" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [55]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" - AFTER UPDATE ON "modelCs" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [56]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" - AFTER UPDATE ON "parents" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'parents', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [57]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" - AFTER UPDATE ON "reminderTags" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'reminderTags', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [58]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" - AFTER UPDATE ON "reminders" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [59]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" - AFTER UPDATE ON "remindersListAssets" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [60]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" - AFTER UPDATE ON "remindersListPrivates" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [61]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" - AFTER UPDATE ON "remindersLists" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."id", 'remindersLists', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """, - [62]: """ - CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" - AFTER UPDATE ON "tags" - FOR EACH ROW BEGIN - WITH "rootShares" AS ( - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) - UNION ALL - SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" - FROM "sqlitedata_icloud_metadata" - JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") - ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') - FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); - INSERT INTO "sqlitedata_icloud_metadata" - ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") - SELECT "new"."title", 'tags', NULL, NULL - ON CONFLICT ("recordPrimaryKey", "recordType") - DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; - END - """ - ] - """# - } - #endif + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [9]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_sync_engine" + AFTER DELETE ON "modelBs" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [10]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_user" + AFTER DELETE ON "modelBs" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [11]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_sync_engine" + AFTER DELETE ON "modelCs" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [12]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_user" + AFTER DELETE ON "modelCs" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [13]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_sync_engine" + AFTER DELETE ON "parents" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [14]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_user" + AFTER DELETE ON "parents" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [15]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_sync_engine" + AFTER DELETE ON "reminderTags" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); + END + """, + [16]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_user" + AFTER DELETE ON "reminderTags" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); + END + """, + [17]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_sync_engine" + AFTER DELETE ON "remindersListAssets" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [18]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_user" + AFTER DELETE ON "remindersListAssets" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [19]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_sync_engine" + AFTER DELETE ON "remindersListPrivates" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [20]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_user" + AFTER DELETE ON "remindersListPrivates" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [21]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_sync_engine" + AFTER DELETE ON "remindersLists" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [22]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_user" + AFTER DELETE ON "remindersLists" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [23]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_sync_engine" + AFTER DELETE ON "reminders" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [24]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_user" + AFTER DELETE ON "reminders" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "old"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [25]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_sync_engine" + AFTER DELETE ON "tags" + FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [26]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_user" + AFTER DELETE ON "tags" + FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [27]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" + AFTER INSERT ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [28]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" + AFTER INSERT ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [29]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" + AFTER INSERT ON "modelAs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelAs', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [30]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" + AFTER INSERT ON "modelBs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [31]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" + AFTER INSERT ON "modelCs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [32]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" + AFTER INSERT ON "parents" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'parents', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [33]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" + AFTER INSERT ON "reminderTags" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminderTags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [34]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" + AFTER INSERT ON "reminders" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [35]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" + AFTER INSERT ON "remindersListAssets" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [36]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" + AFTER INSERT ON "remindersListPrivates" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [37]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" + AFTER INSERT ON "remindersLists" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersLists', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [38]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" + AFTER INSERT ON "tags" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."title", 'tags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [39]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetDefaults" + AFTER UPDATE OF "id" ON "childWithOnDeleteSetDefaults" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); + END + """, + [40]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetNulls" + AFTER UPDATE OF "id" ON "childWithOnDeleteSetNulls" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); + END + """, + [41]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelAs" + AFTER UPDATE OF "id" ON "modelAs" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); + END + """, + [42]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelBs" + AFTER UPDATE OF "id" ON "modelBs" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); + END + """, + [43]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelCs" + AFTER UPDATE OF "id" ON "modelCs" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); + END + """, + [44]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_parents" + AFTER UPDATE OF "id" ON "parents" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); + END + """, + [45]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminderTags" + AFTER UPDATE OF "id" ON "reminderTags" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); + END + """, + [46]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminders" + AFTER UPDATE OF "id" ON "reminders" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); + END + """, + [47]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListAssets" + AFTER UPDATE OF "id" ON "remindersListAssets" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); + END + """, + [48]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListPrivates" + AFTER UPDATE OF "id" ON "remindersListPrivates" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); + END + """, + [49]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersLists" + AFTER UPDATE OF "id" ON "remindersLists" + FOR EACH ROW WHEN ("old"."id" <> "new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); + END + """, + [50]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_tags" + AFTER UPDATE OF "title" ON "tags" + FOR EACH ROW WHEN ("old"."title" <> "new"."title") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); + END + """, + [51]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" + AFTER UPDATE ON "childWithOnDeleteSetDefaults" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [52]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" + AFTER UPDATE ON "childWithOnDeleteSetNulls" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."parentID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'parents')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [53]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" + AFTER UPDATE ON "modelAs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelAs', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [54]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" + AFTER UPDATE ON "modelBs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelAID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelAs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [55]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" + AFTER UPDATE ON "modelCs" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."modelBID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'modelBs')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [56]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" + AFTER UPDATE ON "parents" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'parents', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [57]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" + AFTER UPDATE ON "reminderTags" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminderTags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [58]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" + AFTER UPDATE ON "reminders" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [59]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" + AFTER UPDATE ON "remindersListAssets" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [60]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" + AFTER UPDATE ON "remindersListPrivates" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS "new"."remindersListID") AND ("sqlitedata_icloud_metadata"."recordType" IS 'remindersLists')) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [61]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" + AFTER UPDATE ON "remindersLists" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'remindersLists', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """, + [62]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" + AFTER UPDATE ON "tags" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" IS NULL) AND ("sqlitedata_icloud_metadata"."recordType" IS NULL)) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + FROM "rootShares" + WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."title", 'tags', NULL, NULL + ON CONFLICT ("recordPrimaryKey", "recordType") + DO UPDATE SET "parentRecordPrimaryKey" = "excluded"."parentRecordPrimaryKey", "parentRecordType" = "excluded"."parentRecordType", "userModificationDate" = "excluded"."userModificationDate"; + END + """ + ] + """# + } + #endif - try syncEngine.tearDownSyncEngine() - let triggersAfterTearDown = try await userDatabase.userWrite { db in - try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) - } - assertInlineSnapshot(of: triggersAfterTearDown, as: .customDump) { - """ - [] - """ - } + try syncEngine.tearDownSyncEngine() + let triggersAfterTearDown = try await userDatabase.userWrite { db in + try #sql("SELECT sql FROM sqlite_temp_master", as: String?.self).fetchAll(db) + } + assertInlineSnapshot(of: triggersAfterTearDown, as: .customDump) { + """ + [] + """ + } - try syncEngine.setUpSyncEngine() - try await syncEngine.start() - let triggersAfterReSetUp = try await userDatabase.userWrite { db in - try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) + try syncEngine.setUpSyncEngine() + try await syncEngine.start() + let triggersAfterReSetUp = try await userDatabase.userWrite { db in + try #sql("SELECT sql FROM sqlite_temp_master ORDER BY sql", as: String?.self).fetchAll(db) + } + expectNoDifference(triggersAfterReSetUp, triggersAfterSetUp) } - expectNoDifference(triggersAfterReSetUp, triggersAfterSetUp) } } -} +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift index 1708e5b2..6d90cbfb 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift @@ -1,32 +1,34 @@ -import Foundation -import SQLiteData -import Testing +#if canImport(CloudKit) + import Foundation + import SQLiteData + import Testing -@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 syncEngine = try SyncEngine( - for: database, - tables: ModelA.self, - ModelB.self, - ModelC.self, - containerIdentifier: "tests" - ) + @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 syncEngine = try SyncEngine( + for: database, + tables: ModelA.self, + ModelB.self, + ModelC.self, + containerIdentifier: "tests" + ) - try await withDependencies { - $0.defaultDatabase = database - $0.defaultSyncEngine = syncEngine - $0.datetime.now = Date.init(timeIntervalSince1970: 1) - } operation: { - @FetchAll var modelAs: [ModelA] = [] - try await database.write { db in - try db.seed { - ModelA.Draft() + try await withDependencies { + $0.defaultDatabase = database + $0.defaultSyncEngine = syncEngine + $0.datetime.now = Date.init(timeIntervalSince1970: 1) + } operation: { + @FetchAll var modelAs: [ModelA] = [] + try await database.write { db in + try db.seed { + ModelA.Draft() + } } + try await $modelAs.load() + #expect(modelAs == [ModelA(id: 1, isEven: true)]) } - try await $modelAs.load() - #expect(modelAs == [ModelA(id: 1, isEven: true)]) } } -} +#endif diff --git a/Tests/SQLiteDataTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift index 1442d976..60eda159 100644 --- a/Tests/SQLiteDataTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -1,5 +1,4 @@ import Foundation -import GRDB import SQLiteData import Testing diff --git a/Tests/SQLiteDataTests/FetchAllTests.swift b/Tests/SQLiteDataTests/FetchAllTests.swift index 4d0bdf55..0b787542 100644 --- a/Tests/SQLiteDataTests/FetchAllTests.swift +++ b/Tests/SQLiteDataTests/FetchAllTests.swift @@ -1,11 +1,6 @@ -import Combine -import Dependencies import DependenciesTestSupport import Foundation -import GRDB import SQLiteData -import Sharing -import StructuredQueries import Testing @Suite(.dependency(\.defaultDatabase, try .database())) diff --git a/Tests/SQLiteDataTests/FetchOneTests.swift b/Tests/SQLiteDataTests/FetchOneTests.swift index 32a38823..a0a214b9 100644 --- a/Tests/SQLiteDataTests/FetchOneTests.swift +++ b/Tests/SQLiteDataTests/FetchOneTests.swift @@ -1,11 +1,6 @@ -import Combine -import Dependencies import DependenciesTestSupport import Foundation -import GRDB import SQLiteData -import Sharing -import StructuredQueries import Testing @Suite(.dependency(\.defaultDatabase, try .database())) struct FetchOneTests { diff --git a/Tests/SQLiteDataTests/FetchTests.swift b/Tests/SQLiteDataTests/FetchTests.swift index bcc89731..7461e035 100644 --- a/Tests/SQLiteDataTests/FetchTests.swift +++ b/Tests/SQLiteDataTests/FetchTests.swift @@ -1,9 +1,6 @@ -import Dependencies import DependenciesTestSupport -import GRDB +import Foundation import SQLiteData -import Sharing -import StructuredQueries import Testing @Suite(.dependency(\.defaultDatabase, try .database())) diff --git a/Tests/SQLiteDataTests/IntegrationTests.swift b/Tests/SQLiteDataTests/IntegrationTests.swift index e482f831..8323c923 100644 --- a/Tests/SQLiteDataTests/IntegrationTests.swift +++ b/Tests/SQLiteDataTests/IntegrationTests.swift @@ -1,8 +1,6 @@ -import Dependencies import DependenciesTestSupport +import Foundation import SQLiteData -import Sharing -import StructuredQueries import Testing @Suite(.dependency(\.defaultDatabase, try .syncUps())) diff --git a/Tests/SQLiteDataTests/MigrationTests.swift b/Tests/SQLiteDataTests/MigrationTests.swift index 0bb17714..42e5f24b 100644 --- a/Tests/SQLiteDataTests/MigrationTests.swift +++ b/Tests/SQLiteDataTests/MigrationTests.swift @@ -1,5 +1,4 @@ import Foundation -import GRDB import SQLiteData import Testing diff --git a/Tests/SQLiteDataTests/QueryCursorTests.swift b/Tests/SQLiteDataTests/QueryCursorTests.swift index 4b018cf2..1e608a34 100644 --- a/Tests/SQLiteDataTests/QueryCursorTests.swift +++ b/Tests/SQLiteDataTests/QueryCursorTests.swift @@ -1,4 +1,3 @@ -import GRDB import SQLiteData import Testing diff --git a/Tests/SQLiteDataTests/SharingGRDBTests.swift b/Tests/SQLiteDataTests/SharingGRDBTests.swift deleted file mode 100644 index c0916180..00000000 --- a/Tests/SQLiteDataTests/SharingGRDBTests.swift +++ /dev/null @@ -1,123 +0,0 @@ -import Dependencies -import DependenciesTestSupport -import GRDB -import SQLiteData -import Sharing -import StructuredQueries -import SwiftUI -import Testing - -@Suite struct GRDBSharingTests { - @Test - func fetchOne() throws { - try withDependencies { - $0.defaultDatabase = try DatabaseQueue() - } operation: { - @FetchOne(#sql("SELECT 1")) var bool = false - #expect(bool) - #expect($bool.loadError == nil) - } - } - - @Test - func fetchOneOptional() async throws { - try withDependencies { - $0.defaultDatabase = try DatabaseQueue() - } operation: { - @SharedReader(.fetchOne(sql: "SELECT NULL")) var bool: Bool? - #expect(bool == nil) - } - } - - @Test func fetchSyntaxError() throws { - try withDependencies { - $0.defaultDatabase = try DatabaseQueue() - } operation: { - @FetchOne(#sql("SELEC 1")) var bool = false - #expect(bool == false) - #expect($bool.loadError is DatabaseError?) - let error = try #require($bool.loadError as? DatabaseError) - #expect(error.message == #"near "SELEC": syntax error"#) - } - } - - @Test func fetchWithTwoDatabaseConnections() async throws { - let name = #function - try await withDependencies { - $0.defaultDatabase = try .database(named: name) - } operation: { - @SharedReader(.fetchAll(sql: "SELECT * FROM records")) var records1: [Record] = [] - #expect(records1.map(\.id) == [1, 2, 3]) - - try await withDependencies { - $0.defaultDatabase = try .database(named: name) - } operation: { - @Dependency(\.defaultDatabase) var database2 - @SharedReader(.fetchAll(sql: "SELECT * FROM records")) var records2: [Record] = [] - #expect(records2.map(\.id) == [1, 2, 3]) - try await database2.write { db in - _ = try Record.deleteOne(db, key: 1) - } - try await $records2.load() - #expect(records1.map(\.id) == [1, 2, 3]) - #expect(records2.map(\.id) == [2, 3]) - } - - try await $records1.load() - #expect(records1.map(\.id) == [2, 3]) - } - } - - @Test(.dependency(\.defaultDatabase, try .database())) - func fetchIDHashValue() async throws { - let fetchKey1: some SharedReaderKey = .fetch(Fetch1()) - let fetchKey2: some SharedReaderKey = .fetch(Fetch2()) - #expect(fetchKey1.id.hashValue != fetchKey2.id.hashValue) - } - - @Test(.dependency(\.defaultDatabase, try .database())) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func fetchAnimationHashValue() async throws { - let fetchKey1: some SharedReaderKey = .fetch(Fetch1()) - let fetchKey2: some SharedReaderKey = .fetch(Fetch2(), animation: .default) - #expect(fetchKey1.id.hashValue != fetchKey2.id.hashValue) - } -} - -private struct Fetch1: FetchKeyRequest { - func fetch(_ db: Database) throws { - } -} -private struct Fetch2: FetchKeyRequest { - func fetch(_ db: Database) throws { - } -} - -private struct Record: Codable, Equatable, FetchableRecord, MutablePersistableRecord { - static let databaseTableName = "records" - let id: Int -} -extension DatabaseWriter where Self == DatabaseQueue { - fileprivate static func database(named name: String? = nil) throws -> DatabaseQueue { - let database: DatabaseQueue - if let name { - database = try DatabaseQueue(named: name) - } else { - database = try DatabaseQueue() - } - var migrator = DatabaseMigrator() - migrator.registerMigration("Up") { db in - try #sql( - """ - CREATE TABLE "records" ("id" INTEGER PRIMARY KEY AUTOINCREMENT) - """ - ) - .execute(db) - for index in 1...3 { - _ = try Record(id: index).inserted(db) - } - } - try migrator.migrate(database) - return database - } -} From 35cc9b389804e50032ea0a0bfbae877123cc8cc3 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:37:08 -0700 Subject: [PATCH 520/581] wip --- .../Internal/FetchKey+SwiftUI.swift | 30 --------- Sources/SQLiteData/Internal/FetchKey.swift | 66 ------------------- 2 files changed, 96 deletions(-) diff --git a/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift index 842d5c72..83abd437 100644 --- a/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift +++ b/Sources/SQLiteData/Internal/FetchKey+SwiftUI.swift @@ -22,36 +22,6 @@ where Self == FetchKey.Default { .fetch(request, database: database, scheduler: .animation(animation)) } - - static func fetchAll( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey<[Record]>.Default { - .fetchAll( - sql: sql, - arguments: arguments, - database: database, - scheduler: .animation(animation) - ) - } - - static func fetchOne( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - animation: Animation - ) -> Self - where Self == FetchKey { - .fetchOne( - sql: sql, - arguments: arguments, - database: database, - scheduler: .animation(animation) - ) - } } package struct AnimatedScheduler: ValueObservationScheduler, Equatable { diff --git a/Sources/SQLiteData/Internal/FetchKey.swift b/Sources/SQLiteData/Internal/FetchKey.swift index 3f7f263e..7761d728 100644 --- a/Sources/SQLiteData/Internal/FetchKey.swift +++ b/Sources/SQLiteData/Internal/FetchKey.swift @@ -24,27 +24,6 @@ extension SharedReaderKey { where Self == FetchKey.Default { Self[.fetch(request, database: database), default: Value()] } - - static func fetchAll( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey<[Record]>.Default { - Self[ - .fetch(FetchAllRequest(sql: sql, arguments: arguments), database: database), - default: [] - ] - } - - static func fetchOne( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil - ) -> Self - where Self == FetchKey { - .fetch(FetchOneRequest(sql: sql, arguments: arguments), database: database) - } } extension SharedReaderKey { @@ -65,33 +44,6 @@ extension SharedReaderKey { where Self == FetchKey.Default { Self[.fetch(request, database: database, scheduler: scheduler), default: Value()] } - - static func fetchAll( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey<[Record]>.Default { - Self[ - .fetch( - FetchAllRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler - ), - default: [] - ] - } - - static func fetchOne( - sql: String, - arguments: StatementArguments = StatementArguments(), - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) -> Self - where Self == FetchKey { - .fetch( - FetchOneRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler - ) - } } struct FetchKey: SharedReaderKey { @@ -226,24 +178,6 @@ struct FetchKeyID: Hashable { } } -private struct FetchAllRequest: FetchKeyRequest { - var sql: String - var arguments: StatementArguments = StatementArguments() - func fetch(_ db: Database) throws -> [Element] { - try Element.fetchAll(db, sql: sql, arguments: arguments) - } -} - -private struct FetchOneRequest: FetchKeyRequest { - var sql: String - var arguments: StatementArguments = StatementArguments() - func fetch(_ db: Database) throws -> Value { - guard let value = try Value.fetchOne(db, sql: sql, arguments: arguments) - else { throw NotFound() } - return value - } -} - public struct NotFound: Error { public init() {} } From 86df0239b2de9648748512e2d0a2708789ba74f1 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:44:10 -0700 Subject: [PATCH 521/581] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 18 +- ...ndingRecordZoneChange+MacroExpansion.swift | 41 -- .../CloudKit/PendingRecordZoneChange.swift | 4 +- .../CloudKit/RecordType+MacroExpansion.swift | 129 ------ Sources/SQLiteData/CloudKit/RecordType.swift | 6 +- .../StateSerialization+MacroExpansion.swift | 110 ----- .../CloudKit/StateSerialization.swift | 6 +- .../SyncMetadata+MacroExpansion.swift | 379 ------------------ .../SQLiteData/CloudKit/SyncMetadata.swift | 29 +- .../UnsyncedRecordID+MacroExpansion.swift | 54 --- .../CloudKit/UnsyncedRecordID.swift | 2 +- 11 files changed, 32 insertions(+), 746 deletions(-) delete mode 100644 Sources/SQLiteData/CloudKit/PendingRecordZoneChange+MacroExpansion.swift delete mode 100644 Sources/SQLiteData/CloudKit/RecordType+MacroExpansion.swift delete mode 100644 Sources/SQLiteData/CloudKit/StateSerialization+MacroExpansion.swift delete mode 100644 Sources/SQLiteData/CloudKit/SyncMetadata+MacroExpansion.swift delete mode 100644 Sources/SQLiteData/CloudKit/UnsyncedRecordID+MacroExpansion.swift diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 545efe20..a749d5f5 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -5,17 +5,17 @@ import StructuredQueriesCore extension _CKRecord where Self == CKRecord { - typealias AllFieldsRepresentation = _AllFieldsRepresentation + public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } extension _CKRecord where Self == CKShare { - typealias AllFieldsRepresentation = _AllFieldsRepresentation + public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation } extension Optional where Wrapped: CKRecord { - package typealias AllFieldsRepresentation = _AllFieldsRepresentation? + public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation? public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? } @@ -61,10 +61,10 @@ private struct DecodingError: Error {} } - package struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { - package let queryOutput: Record + public struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: Record - package var queryBinding: QueryBinding { + public var queryBinding: QueryBinding { let archiver = NSKeyedArchiver(requiringSecureCoding: true) queryOutput.encode(with: archiver) if isTesting { @@ -73,16 +73,16 @@ return archiver.encodedData.queryBinding } - package init(queryOutput: Record) { + public init(queryOutput: Record) { self.queryOutput = queryOutput } - package init?(queryBinding: QueryBinding) { + public init?(queryBinding: QueryBinding) { guard case .blob(let bytes) = queryBinding else { return nil } try? self.init(data: Data(bytes)) } - package init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { try self.init(data: try Data(decoder: &decoder)) } diff --git a/Sources/SQLiteData/CloudKit/PendingRecordZoneChange+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/PendingRecordZoneChange+MacroExpansion.swift deleted file mode 100644 index 6ea13bc7..00000000 --- a/Sources/SQLiteData/CloudKit/PendingRecordZoneChange+MacroExpansion.swift +++ /dev/null @@ -1,41 +0,0 @@ -#if canImport(CloudKit) - import CloudKit - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension PendingRecordZoneChange { - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = PendingRecordZoneChange - public let pendingRecordZoneChange = StructuredQueriesCore.TableColumn< - QueryValue, CKSyncEngine.PendingRecordZoneChange.DataRepresentation - >("pendingRecordZoneChange", keyPath: \QueryValue.pendingRecordZoneChange) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.pendingRecordZoneChange] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { - [QueryValue.columns.pendingRecordZoneChange] - } - public var queryFragment: QueryFragment { - "\(self.pendingRecordZoneChange)" - } - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - nonisolated extension PendingRecordZoneChange: StructuredQueriesCore.Table { - public nonisolated static var columns: TableColumns { - TableColumns() - } - public nonisolated static var tableName: String { - "sqlitedata_icloud_pendingRecordZoneChanges" - } - public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let pendingRecordZoneChange = try decoder.decode( - CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self - ) - guard let pendingRecordZoneChange else { - throw QueryDecodingError.missingRequiredColumn - } - self.pendingRecordZoneChange = pendingRecordZoneChange - } - } -#endif diff --git a/Sources/SQLiteData/CloudKit/PendingRecordZoneChange.swift b/Sources/SQLiteData/CloudKit/PendingRecordZoneChange.swift index 03481e57..fad64e0d 100644 --- a/Sources/SQLiteData/CloudKit/PendingRecordZoneChange.swift +++ b/Sources/SQLiteData/CloudKit/PendingRecordZoneChange.swift @@ -1,10 +1,10 @@ #if canImport(CloudKit) import CloudKit - // @Table("\(String.sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges") + @Table("sqlitedata_icloud_pendingRecordZoneChanges") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package struct PendingRecordZoneChange { - // @Column(as: CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self) + @Column(as: CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self) package let pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange } diff --git a/Sources/SQLiteData/CloudKit/RecordType+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/RecordType+MacroExpansion.swift deleted file mode 100644 index cacce5ac..00000000 --- a/Sources/SQLiteData/CloudKit/RecordType+MacroExpansion.swift +++ /dev/null @@ -1,129 +0,0 @@ -#if canImport(CloudKit) - import StructuredQueriesCore - - extension RecordType { - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition, - StructuredQueriesCore.PrimaryKeyedTableDefinition - { - public typealias QueryValue = RecordType - public let tableName = StructuredQueriesCore.TableColumn( - "tableName", - keyPath: \QueryValue.tableName - ) - public let schema = StructuredQueriesCore.TableColumn( - "schema", - keyPath: \QueryValue.schema - ) - public let tableInfo = StructuredQueriesCore.TableColumn< - QueryValue, Set.JSONRepresentation - >("tableInfo", keyPath: \QueryValue.tableInfo) - public var primaryKey: StructuredQueriesCore.TableColumn { - self.tableName - } - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.tableName, QueryValue.columns.schema, QueryValue.columns.tableInfo] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { - [QueryValue.columns.tableName, QueryValue.columns.schema, QueryValue.columns.tableInfo] - } - public var queryFragment: QueryFragment { - "\(self.tableName), \(self.schema), \(self.tableInfo)" - } - } - - public struct Draft: StructuredQueriesCore.TableDraft { - public typealias PrimaryTable = RecordType - package let tableName: String? - package let schema: String - package let tableInfo: Set - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = Draft - public let tableName = StructuredQueriesCore.TableColumn( - "tableName", - keyPath: \QueryValue.tableName - ) - public let schema = StructuredQueriesCore.TableColumn( - "schema", - keyPath: \QueryValue.schema - ) - public let tableInfo = StructuredQueriesCore.TableColumn< - QueryValue, Set.JSONRepresentation - >("tableInfo", keyPath: \QueryValue.tableInfo) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.tableName, QueryValue.columns.schema, QueryValue.columns.tableInfo] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] - { - [QueryValue.columns.tableName, QueryValue.columns.schema, QueryValue.columns.tableInfo] - } - public var queryFragment: QueryFragment { - "\(self.tableName), \(self.schema), \(self.tableInfo)" - } - } - public nonisolated static var columns: TableColumns { - TableColumns() - } - - public nonisolated static var tableName: String { - RecordType.tableName - } - - public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - self.tableName = try decoder.decode(String.self) - let schema = try decoder.decode(String.self) - let tableInfo = try decoder.decode(Set.JSONRepresentation.self) - guard let schema else { - throw QueryDecodingError.missingRequiredColumn - } - guard let tableInfo else { - throw QueryDecodingError.missingRequiredColumn - } - self.schema = schema - self.tableInfo = tableInfo - } - - public nonisolated init(_ other: RecordType) { - self.tableName = other.tableName - self.schema = other.schema - self.tableInfo = other.tableInfo - } - public init( - tableName: String? = nil, - schema: String, - tableInfo: Set - ) { - self.tableName = tableName - self.schema = schema - self.tableInfo = tableInfo - } - } - } - - nonisolated extension RecordType: StructuredQueriesCore.Table, StructuredQueriesCore - .PrimaryKeyedTable - { - public nonisolated static var columns: TableColumns { - TableColumns() - } - public nonisolated static var tableName: String { - "sqlitedata_icloud_recordTypes" - } - public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let tableName = try decoder.decode(String.self) - let schema = try decoder.decode(String.self) - let tableInfo = try decoder.decode(Set.JSONRepresentation.self) - guard let tableName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let schema else { - throw QueryDecodingError.missingRequiredColumn - } - guard let tableInfo else { - throw QueryDecodingError.missingRequiredColumn - } - self.tableName = tableName - self.schema = schema - self.tableInfo = tableInfo - } - } -#endif diff --git a/Sources/SQLiteData/CloudKit/RecordType.swift b/Sources/SQLiteData/CloudKit/RecordType.swift index 4c1fee06..78789acd 100644 --- a/Sources/SQLiteData/CloudKit/RecordType.swift +++ b/Sources/SQLiteData/CloudKit/RecordType.swift @@ -1,11 +1,11 @@ import CustomDump -// @Table("\(String.sqliteDataCloudKitSchemaName)_recordTypes") +@Table("sqlitedata_icloud_recordTypes") package struct RecordType: Hashable { - // @Column(primaryKey: true) + @Column(primaryKey: true) package let tableName: String package let schema: String - // @Column(as: Set.JSONRepresentation.self) + @Column(as: Set.JSONRepresentation.self) package let tableInfo: Set } diff --git a/Sources/SQLiteData/CloudKit/StateSerialization+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/StateSerialization+MacroExpansion.swift deleted file mode 100644 index 4abf88a3..00000000 --- a/Sources/SQLiteData/CloudKit/StateSerialization+MacroExpansion.swift +++ /dev/null @@ -1,110 +0,0 @@ -#if canImport(CloudKit) - import CloudKit - import StructuredQueriesCore - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension StateSerialization { - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition, - StructuredQueriesCore.PrimaryKeyedTableDefinition - { - public typealias QueryValue = StateSerialization - public let scope = StructuredQueriesCore.TableColumn< - QueryValue, CKDatabase.Scope.RawValueRepresentation - >("scope", keyPath: \QueryValue.scope) - public let data = StructuredQueriesCore.TableColumn< - QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation - >("data", keyPath: \QueryValue.data) - public var primaryKey: - StructuredQueriesCore.TableColumn - { - self.scope - } - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.scope, QueryValue.columns.data] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { - [QueryValue.columns.scope, QueryValue.columns.data] - } - public var queryFragment: QueryFragment { - "\(self.scope), \(self.data)" - } - } - - public struct Draft: StructuredQueriesCore.TableDraft { - public typealias PrimaryTable = StateSerialization - package var scope: CKDatabase.Scope? - package var data: CKSyncEngine.State.Serialization - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = Draft - public let scope = StructuredQueriesCore.TableColumn< - QueryValue, CKDatabase.Scope.RawValueRepresentation? - >("scope", keyPath: \QueryValue.scope) - public let data = StructuredQueriesCore.TableColumn< - QueryValue, CKSyncEngine.State.Serialization.JSONRepresentation - >("data", keyPath: \QueryValue.data) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.scope, QueryValue.columns.data] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] - { - [QueryValue.columns.scope, QueryValue.columns.data] - } - public var queryFragment: QueryFragment { - "\(self.scope), \(self.data)" - } - } - public nonisolated static var columns: TableColumns { - TableColumns() - } - - public nonisolated static var tableName: String { - StateSerialization.tableName - } - - public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - self.scope = try decoder.decode(CKDatabase.Scope.RawValueRepresentation.self) - let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) - guard let data else { - throw QueryDecodingError.missingRequiredColumn - } - self.data = data - } - - public nonisolated init(_ other: StateSerialization) { - self.scope = other.scope - self.data = other.data - } - public init( - scope: CKDatabase.Scope? = nil, - data: CKSyncEngine.State.Serialization - ) { - self.scope = scope - self.data = data - } - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - nonisolated extension StateSerialization: StructuredQueriesCore.Table, StructuredQueriesCore - .PrimaryKeyedTable - { - public nonisolated static var columns: TableColumns { - TableColumns() - } - public nonisolated static var tableName: String { - "sqlitedata_icloud_stateSerialization" - } - public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let scope = try decoder.decode(CKDatabase.Scope.RawValueRepresentation.self) - let data = try decoder.decode(CKSyncEngine.State.Serialization.JSONRepresentation.self) - guard let scope else { - throw QueryDecodingError.missingRequiredColumn - } - guard let data else { - throw QueryDecodingError.missingRequiredColumn - } - self.scope = scope - self.data = data - } - } -#endif diff --git a/Sources/SQLiteData/CloudKit/StateSerialization.swift b/Sources/SQLiteData/CloudKit/StateSerialization.swift index 79ccb114..2b76b33b 100644 --- a/Sources/SQLiteData/CloudKit/StateSerialization.swift +++ b/Sources/SQLiteData/CloudKit/StateSerialization.swift @@ -2,12 +2,12 @@ import CloudKit import StructuredQueriesCore - // @Table("\(String.sqliteDataCloudKitSchemaName)_stateSerialization") + @Table("sqlitedata_icloud_stateSerialization") @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package struct StateSerialization { - // @Column(as: CKDatabase.Scope.RawValueRepresentation.self, primaryKey: true) + @Column(as: CKDatabase.Scope.RawValueRepresentation.self, primaryKey: true) package var scope: CKDatabase.Scope - // @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) + @Column(as: CKSyncEngine.State.Serialization.JSONRepresentation.self) package var data: CKSyncEngine.State.Serialization } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/SyncMetadata+MacroExpansion.swift deleted file mode 100644 index 52ae476f..00000000 --- a/Sources/SQLiteData/CloudKit/SyncMetadata+MacroExpansion.swift +++ /dev/null @@ -1,379 +0,0 @@ -#if canImport(CloudKit) - import CloudKit - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncMetadata { - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = SyncMetadata - public let recordPrimaryKey = StructuredQueriesCore.TableColumn( - "recordPrimaryKey", - keyPath: \QueryValue.recordPrimaryKey - ) - public let recordType = StructuredQueriesCore.TableColumn( - "recordType", - keyPath: \QueryValue.recordType - ) - public var recordName: StructuredQueriesCore.GeneratedColumn { - StructuredQueriesCore.GeneratedColumn( - "recordName", - keyPath: \QueryValue.recordName - ) - } - public let parentRecordPrimaryKey = StructuredQueriesCore.TableColumn( - "parentRecordPrimaryKey", - keyPath: \QueryValue.parentRecordPrimaryKey - ) - public let parentRecordType = StructuredQueriesCore.TableColumn( - "parentRecordType", - keyPath: \QueryValue.parentRecordType - ) - public var parentRecordName: StructuredQueriesCore.GeneratedColumn { - StructuredQueriesCore.GeneratedColumn( - "parentRecordName", - keyPath: \QueryValue.parentRecordName - ) - } - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord?.SystemFieldsRepresentation - >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - package let _lastKnownServerRecordAllFields = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord?.AllFieldsRepresentation - >("_lastKnownServerRecordAllFields", keyPath: \QueryValue._lastKnownServerRecordAllFields) - public let share = StructuredQueriesCore.TableColumn< - QueryValue, CKShare?.SystemFieldsRepresentation - >("share", keyPath: \QueryValue.share) - public var isShared: StructuredQueriesCore.GeneratedColumn { - StructuredQueriesCore.GeneratedColumn( - "isShared", - keyPath: \QueryValue.isShared - ) - } - public var _isDeleted: StructuredQueriesCore.TableColumn { - StructuredQueriesCore.TableColumn( - "_isDeleted", - keyPath: \QueryValue._isDeleted - ) - } - public let userModificationDate = StructuredQueriesCore.TableColumn( - "userModificationDate", - keyPath: \QueryValue.userModificationDate - ) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [ - QueryValue.columns.recordPrimaryKey, QueryValue.columns.recordType, - QueryValue.columns.recordName, QueryValue.columns.parentRecordPrimaryKey, - QueryValue.columns.parentRecordType, QueryValue.columns.parentRecordName, - QueryValue.columns.lastKnownServerRecord, - QueryValue.columns._lastKnownServerRecordAllFields, QueryValue.columns.share, - QueryValue.columns.isShared, QueryValue.columns._isDeleted, - QueryValue.columns.userModificationDate, - ] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { - [ - QueryValue.columns.recordPrimaryKey, QueryValue.columns.recordType, - QueryValue.columns.parentRecordPrimaryKey, QueryValue.columns.parentRecordType, - QueryValue.columns.lastKnownServerRecord, - QueryValue.columns._lastKnownServerRecordAllFields, QueryValue.columns.share, - QueryValue.columns._isDeleted, - QueryValue.columns.userModificationDate, - ] - } - public var queryFragment: QueryFragment { - "\(self.recordPrimaryKey), \(self.recordType), \(self.recordName), \(self.parentRecordPrimaryKey), \(self.parentRecordType), \(self.parentRecordName), \(self.lastKnownServerRecord), \(self._lastKnownServerRecordAllFields), \(self.share), \(self.isShared), \(self._isDeleted), \(self.userModificationDate)" - } - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - nonisolated extension SyncMetadata: StructuredQueriesCore.Table { - public nonisolated static var columns: TableColumns { - TableColumns() - } - public nonisolated static var tableName: String { - "sqlitedata_icloud_metadata" - } - public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let recordPrimaryKey = try decoder.decode(String.self) - let recordType = try decoder.decode(String.self) - let recordName = try decoder.decode(String.self) - self.parentRecordPrimaryKey = try decoder.decode(String.self) - self.parentRecordType = try decoder.decode(String.self) - self.parentRecordName = try decoder.decode(String.self) - let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) - let _lastKnownServerRecordAllFields = try decoder.decode( - CKRecord?.AllFieldsRepresentation.self - ) - let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) - let isShared = try decoder.decode(Bool.self) - let _isDeleted = try decoder.decode(Bool.self) - let userModificationDate = try decoder.decode(Date.self) - guard let recordPrimaryKey else { - throw QueryDecodingError.missingRequiredColumn - } - guard let recordType else { - throw QueryDecodingError.missingRequiredColumn - } - guard let recordName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let lastKnownServerRecord else { - throw QueryDecodingError.missingRequiredColumn - } - guard let _lastKnownServerRecordAllFields else { - throw QueryDecodingError.missingRequiredColumn - } - guard let share else { - throw QueryDecodingError.missingRequiredColumn - } - guard let isShared else { - throw QueryDecodingError.missingRequiredColumn - } - guard let _isDeleted else { - throw QueryDecodingError.missingRequiredColumn - } - guard let userModificationDate else { - throw QueryDecodingError.missingRequiredColumn - } - self.recordPrimaryKey = recordPrimaryKey - self.recordType = recordType - self.recordName = recordName - self.lastKnownServerRecord = lastKnownServerRecord - self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields - self.share = share - self.isShared = isShared - self._isDeleted = _isDeleted - self.userModificationDate = userModificationDate - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension AncestorMetadata { - public struct Columns: StructuredQueriesCore.QueryExpression { - public typealias QueryValue = AncestorMetadata - public let queryFragment: StructuredQueriesCore.QueryFragment - public init( - recordName: some StructuredQueriesCore.QueryExpression, - parentRecordName: some StructuredQueriesCore.QueryExpression, - lastKnownServerRecord: some StructuredQueriesCore.QueryExpression< - CKRecord?.SystemFieldsRepresentation - > - ) { - self.queryFragment = """ - \(recordName.queryFragment) AS "recordName", \(parentRecordName.queryFragment) AS "parentRecordName", \(lastKnownServerRecord.queryFragment) AS "lastKnownServerRecord" - """ - } - } - - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = AncestorMetadata - public let recordName = StructuredQueriesCore.TableColumn( - "recordName", - keyPath: \QueryValue.recordName - ) - public let parentRecordName = StructuredQueriesCore.TableColumn( - "parentRecordName", - keyPath: \QueryValue.parentRecordName - ) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord?.SystemFieldsRepresentation - >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [ - QueryValue.columns.recordName, QueryValue.columns.parentRecordName, - QueryValue.columns.lastKnownServerRecord, - ] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { - [ - QueryValue.columns.recordName, QueryValue.columns.parentRecordName, - QueryValue.columns.lastKnownServerRecord, - ] - } - public var queryFragment: QueryFragment { - "\(self.recordName), \(self.parentRecordName), \(self.lastKnownServerRecord)" - } - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - nonisolated extension AncestorMetadata: StructuredQueriesCore.Table, StructuredQueriesCore - .PartialSelectStatement - { - public typealias QueryValue = Self - public typealias From = Swift.Never - public nonisolated static var columns: TableColumns { - TableColumns() - } - public nonisolated static var tableName: String { - "ancestorMetadatas" - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension AncestorMetadata: StructuredQueriesCore.QueryRepresentable { - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let recordName = try decoder.decode(String.self) - let parentRecordName = try decoder.decode(String.self) - let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) - guard let recordName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let lastKnownServerRecord else { - throw QueryDecodingError.missingRequiredColumn - } - self.recordName = recordName - self.parentRecordName = parentRecordName - self.lastKnownServerRecord = lastKnownServerRecord - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension RecordWithRoot { - public struct Columns: StructuredQueriesCore.QueryExpression { - public typealias QueryValue = RecordWithRoot - public let queryFragment: StructuredQueriesCore.QueryFragment - public init( - parentRecordName: some StructuredQueriesCore.QueryExpression, - recordName: some StructuredQueriesCore.QueryExpression, - lastKnownServerRecord: some StructuredQueriesCore.QueryExpression< - CKRecord?.SystemFieldsRepresentation - >, - rootRecordName: some StructuredQueriesCore.QueryExpression, - rootLastKnownServerRecord: some StructuredQueriesCore.QueryExpression< - CKRecord?.SystemFieldsRepresentation - > - ) { - self.queryFragment = """ - \(parentRecordName.queryFragment) AS "parentRecordName", \(recordName.queryFragment) AS "recordName", \(lastKnownServerRecord.queryFragment) AS "lastKnownServerRecord", \(rootRecordName.queryFragment) AS "rootRecordName", \(rootLastKnownServerRecord.queryFragment) AS "rootLastKnownServerRecord" - """ - } - } - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = RecordWithRoot - public let parentRecordName = StructuredQueriesCore.TableColumn( - "parentRecordName", - keyPath: \QueryValue.parentRecordName - ) - public let recordName = StructuredQueriesCore.TableColumn( - "recordName", - keyPath: \QueryValue.recordName - ) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord?.SystemFieldsRepresentation - >("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let rootRecordName = StructuredQueriesCore.TableColumn( - "rootRecordName", - keyPath: \QueryValue.rootRecordName - ) - public let rootLastKnownServerRecord = StructuredQueriesCore.TableColumn< - QueryValue, CKRecord?.SystemFieldsRepresentation - >("rootLastKnownServerRecord", keyPath: \QueryValue.rootLastKnownServerRecord) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [ - QueryValue.columns.parentRecordName, QueryValue.columns.recordName, - QueryValue.columns.lastKnownServerRecord, QueryValue.columns.rootRecordName, - QueryValue.columns.rootLastKnownServerRecord, - ] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { - [ - QueryValue.columns.parentRecordName, QueryValue.columns.recordName, - QueryValue.columns.lastKnownServerRecord, QueryValue.columns.rootRecordName, - QueryValue.columns.rootLastKnownServerRecord, - ] - } - public var queryFragment: QueryFragment { - "\(self.parentRecordName), \(self.recordName), \(self.lastKnownServerRecord), \(self.rootRecordName), \(self.rootLastKnownServerRecord)" - } - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - nonisolated extension RecordWithRoot: StructuredQueriesCore.Table { - public nonisolated static var columns: TableColumns { - TableColumns() - } - public nonisolated static var tableName: String { - "recordWithRoots" - } - public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - self.parentRecordName = try decoder.decode(String.self) - let recordName = try decoder.decode(String.self) - let lastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) - let rootRecordName = try decoder.decode(String.self) - let rootLastKnownServerRecord = try decoder.decode(CKRecord?.SystemFieldsRepresentation.self) - guard let recordName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let lastKnownServerRecord else { - throw QueryDecodingError.missingRequiredColumn - } - guard let rootRecordName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let rootLastKnownServerRecord else { - throw QueryDecodingError.missingRequiredColumn - } - self.recordName = recordName - self.lastKnownServerRecord = lastKnownServerRecord - self.rootRecordName = rootRecordName - self.rootLastKnownServerRecord = rootLastKnownServerRecord - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension RootShare { - public struct Columns: StructuredQueriesCore.QueryExpression { - public typealias QueryValue = RootShare - public let queryFragment: StructuredQueriesCore.QueryFragment - public init( - parentRecordName: some StructuredQueriesCore.QueryExpression, - share: some StructuredQueriesCore.QueryExpression - ) { - self.queryFragment = """ - \(parentRecordName.queryFragment) AS "parentRecordName", \(share.queryFragment) AS "share" - """ - } - } - - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = RootShare - public let parentRecordName = StructuredQueriesCore.TableColumn( - "parentRecordName", - keyPath: \QueryValue.parentRecordName - ) - public let share = StructuredQueriesCore.TableColumn< - QueryValue, CKShare?.SystemFieldsRepresentation - >("share", keyPath: \QueryValue.share) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.parentRecordName, QueryValue.columns.share] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { - [QueryValue.columns.parentRecordName, QueryValue.columns.share] - } - public var queryFragment: QueryFragment { - "\(self.parentRecordName), \(self.share)" - } - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - nonisolated extension RootShare: StructuredQueriesCore.Table { - public nonisolated static var columns: TableColumns { - TableColumns() - } - public nonisolated static var tableName: String { - "rootShares" - } - public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - self.parentRecordName = try decoder.decode(String.self) - let share = try decoder.decode(CKShare?.SystemFieldsRepresentation.self) - guard let share else { - throw QueryDecodingError.missingRequiredColumn - } - self.share = share - } - } - -#endif diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 3a4833a8..01dc0aab 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -10,7 +10,7 @@ /// /// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - // @Table("\(String.sqliteDataCloudKitSchemaName)_metadata") + @Table("sqlitedata_icloud_metadata") public struct SyncMetadata: Hashable, Sendable { /// The unique identifier of the record synchronized. public var recordPrimaryKey: String @@ -26,7 +26,7 @@ /// ```swift /// "8c4d1e4e-49b2-4f60-b6df-3c23881b87c6:reminders" /// ``` - // @Column(generated: .virtual) + @Column(generated: .virtual) public let recordName: String /// The unique identifier of this record's parent, if any. @@ -43,21 +43,21 @@ /// ```swift /// "d35e1f81-46e4-45d1-904b-2b7df1661e3e:remindersLists" /// ``` - // @Column(generated: .virtual) + @Column(generated: .virtual) public let parentRecordName: String? /// The last known `CKRecord` received from the server. /// /// This record holds only the fields that are archived when using `encodeSystemFields(with:)`. - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + @Column(as: CKRecord?.SystemFieldsRepresentation.self) public var lastKnownServerRecord: CKRecord? /// The last known `CKRecord` received from the server with all fields archived. - // @Column(as: CKRecord?.AllFieldsRepresentation.self) - package var _lastKnownServerRecordAllFields: CKRecord? + @Column(as: CKRecord?._AllFieldsRepresentation.self) + public var _lastKnownServerRecordAllFields: CKRecord? /// The `CKShare` associated with this record, if it is shared. - // @Column(as: CKShare?.SystemFieldsRepresentation.self) + @Column(as: CKShare?.SystemFieldsRepresentation.self) public var share: CKShare? /// Determines if the metadata has been "soft" deleted. It will be fully deleted once the @@ -72,31 +72,31 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - // @Table @Selection + @Table @Selection struct AncestorMetadata { let recordName: String let parentRecordName: String? - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + @Column(as: CKRecord?.SystemFieldsRepresentation.self) let lastKnownServerRecord: CKRecord? } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - // @Table @Selection + @Table @Selection struct RecordWithRoot { let parentRecordName: String? let recordName: String - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + @Column(as: CKRecord?.SystemFieldsRepresentation.self) let lastKnownServerRecord: CKRecord? let rootRecordName: String - // @Column(as: CKRecord?.SystemFieldsRepresentation.self) + @Column(as: CKRecord?.SystemFieldsRepresentation.self) let rootLastKnownServerRecord: CKRecord? } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - // @Table @Selection + @Table @Selection struct RootShare { let parentRecordName: String? - // @Column(as: CKShare?.SystemFieldsRepresentation.self) + @Column(as: CKShare?.SystemFieldsRepresentation.self) let share: CKShare? } @@ -128,7 +128,6 @@ self.isShared = share != nil self.userModificationDate = userModificationDate } - } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Sources/SQLiteData/CloudKit/UnsyncedRecordID+MacroExpansion.swift b/Sources/SQLiteData/CloudKit/UnsyncedRecordID+MacroExpansion.swift deleted file mode 100644 index a56e54b2..00000000 --- a/Sources/SQLiteData/CloudKit/UnsyncedRecordID+MacroExpansion.swift +++ /dev/null @@ -1,54 +0,0 @@ -import StructuredQueriesCore - -extension UnsyncedRecordID { - public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition { - public typealias QueryValue = UnsyncedRecordID - public let recordName = StructuredQueriesCore.TableColumn( - "recordName", - keyPath: \QueryValue.recordName - ) - public let zoneName = StructuredQueriesCore.TableColumn( - "zoneName", - keyPath: \QueryValue.zoneName - ) - public let ownerName = StructuredQueriesCore.TableColumn( - "ownerName", - keyPath: \QueryValue.ownerName - ) - public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] { - [QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName] - } - public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { - [QueryValue.columns.recordName, QueryValue.columns.zoneName, QueryValue.columns.ownerName] - } - public var queryFragment: QueryFragment { - "\(self.recordName), \(self.zoneName), \(self.ownerName)" - } - } -} - -nonisolated extension UnsyncedRecordID: StructuredQueriesCore.Table { - public nonisolated static var columns: TableColumns { - TableColumns() - } - public nonisolated static var tableName: String { - "sqlitedata_icloud_unsyncedRecordIDs" - } - public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - let recordName = try decoder.decode(String.self) - let zoneName = try decoder.decode(String.self) - let ownerName = try decoder.decode(String.self) - guard let recordName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let zoneName else { - throw QueryDecodingError.missingRequiredColumn - } - guard let ownerName else { - throw QueryDecodingError.missingRequiredColumn - } - self.recordName = recordName - self.zoneName = zoneName - self.ownerName = ownerName - } -} diff --git a/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift b/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift index 0ec9e31e..12d769b4 100644 --- a/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift +++ b/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift @@ -2,7 +2,7 @@ import CloudKit import StructuredQueriesCore - // @Table("\(String.sqliteDataCloudKitSchemaName)_unsyncedRecordIDs") + @Table("sqlitedata_icloud_unsyncedRecordIDs") package struct UnsyncedRecordID: Equatable { package let recordName: String package let zoneName: String From 8cd6d0e59a8e98d78d25627c4aa20dc8337e51b7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:49:43 -0700 Subject: [PATCH 522/581] wip --- .../CloudKit/CloudKitFunctions.swift | 9 +++++++ .../SQLiteData/CloudKit/Metadatabase.swift | 8 +----- Sources/SQLiteData/CloudKit/SyncEngine.swift | 26 +++++++++---------- .../SQLiteData/CloudKit/SyncMetadata.swift | 2 +- 4 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 Sources/SQLiteData/CloudKit/CloudKitFunctions.swift diff --git a/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift b/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift new file mode 100644 index 00000000..44c4a7a5 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift @@ -0,0 +1,9 @@ +#if canImport(CloudKit) + import Foundation + + @DatabaseFunction("sqlitedata_icloud_datetime") + func datetime() -> Date { + @Dependency(\.datetime.now) var now + return now + } +#endif diff --git a/Sources/SQLiteData/CloudKit/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Metadatabase.swift index 437c2e0b..6327817a 100644 --- a/Sources/SQLiteData/CloudKit/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Metadatabase.swift @@ -63,7 +63,7 @@ "_lastKnownServerRecordAllFields" BLOB, "share" BLOB, "isShared" INTEGER NOT NULL AS ("share" IS NOT NULL), - "userModificationDate" TEXT NOT NULL DEFAULT (\(.datetime())), + "userModificationDate" TEXT NOT NULL DEFAULT (\($datetime())), "_isDeleted" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY ("recordPrimaryKey", "recordType"), @@ -130,10 +130,4 @@ try migrator.migrate(metadatabase) return metadatabase } - - extension QueryFragment { - static func datetime() -> Self { - Self("\(raw: .sqliteDataCloudKitSchemaName)_datetime()") - } - } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 56dc335c..dc644d93 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -233,7 +233,7 @@ ) .execute(db) } - db.add(function: .datetime) + db.add(function: $datetime) db.add(function: .syncEngineIsSynchronizingChanges) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) @@ -438,7 +438,7 @@ db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: .syncEngineIsSynchronizingChanges) - db.remove(function: .datetime) + db.remove(function: $datetime) // TODO: Do an `.erase()` + re-migrate try SyncMetadata.delete().execute(db) try RecordType.delete().execute(db) @@ -1596,17 +1596,17 @@ } } - fileprivate static var datetime: Self { - Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in - @Dependency(\.datetime.now) var now - return now.formatted( - .iso8601 - .year().month().day() - .dateTimeSeparator(.space) - .time(includingFractionalSeconds: true) - ) - } - } +// fileprivate static var datetime: Self { +// Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in +// @Dependency(\.datetime.now) var now +// return now.formatted( +// .iso8601 +// .year().month().day() +// .dateTimeSeparator(.space) +// .time(includingFractionalSeconds: true) +// ) +// } +// } fileprivate static var hasPermission: Self { Self(.sqliteDataCloudKitSchemaName + "_hasPermission", argumentCount: 1) { arguments in diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 01dc0aab..dbcf9b61 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -64,7 +64,7 @@ /// next batch of pending changes is processed. public var _isDeleted = false - // @Column(generated: .virtual) + @Column(generated: .virtual) public let isShared: Bool /// The date the user last modified the record. From d14c3ae72de1b4b6a248c9d5a225c5cad1355778 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:55:40 -0700 Subject: [PATCH 523/581] wip --- .../CloudKit/CloudKitFunctions.swift | 6 ++++++ Sources/SQLiteData/CloudKit/SyncEngine.swift | 18 ++++-------------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift b/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift index 44c4a7a5..2d85f7d7 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift @@ -6,4 +6,10 @@ @Dependency(\.datetime.now) var now return now } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @DatabaseFunction("sqlitedata_icloud_syncEngineIsSynchronizingChanges") + func syncEngineIsSynchronizingChanges() -> Bool { + SyncEngine._isSynchronizingChanges + } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index dc644d93..1426b4ab 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -234,7 +234,7 @@ .execute(db) } db.add(function: $datetime) - db.add(function: .syncEngineIsSynchronizingChanges) + db.add(function: $syncEngineIsSynchronizingChanges) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) db.add(function: .hasPermission) @@ -437,7 +437,7 @@ db.remove(function: .hasPermission) db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) - db.remove(function: .syncEngineIsSynchronizingChanges) + db.remove(function: $syncEngineIsSynchronizingChanges) db.remove(function: $datetime) // TODO: Do an `.erase()` + re-migrate try SyncMetadata.delete().execute(db) @@ -539,8 +539,8 @@ ) } - public static func isSynchronizingChanges() -> SQLQueryExpression { - SQLQueryExpression("\(raw: DatabaseFunction.syncEngineIsSynchronizingChanges.name)()") + public static func isSynchronizingChanges() -> some QueryExpression { + $syncEngineIsSynchronizingChanges() } } @@ -1624,16 +1624,6 @@ } } - fileprivate static var syncEngineIsSynchronizingChanges: Self { - Self( - .sqliteDataCloudKitSchemaName + "_" + "syncEngineIsSynchronizingChanges", - argumentCount: 0 - ) { - _ in - SyncEngine._isSynchronizingChanges - } - } - private convenience init( _ name: String, function: @escaping @Sendable (String, CKRecordZone.ID?, CKShare?) -> Void From f5e970715ae06f70b327ff3327b1107c1ae8ebe4 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 16:56:51 -0700 Subject: [PATCH 524/581] wip --- .../CloudKitTests/TriggerTests.swift | 150 +++++++++--------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index ded0c457..d189c4f4 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -21,7 +21,7 @@ [0]: """ CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" AFTER UPDATE OF "_isDeleted" ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN ((NOT ("old"."_isDeleted") AND "new"."_isDeleted") AND NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges())) BEGIN + FOR EACH ROW WHEN ((NOT ("old"."_isDeleted") AND "new"."_isDeleted") AND NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN SELECT sqlitedata_icloud_didDelete("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" @@ -41,7 +41,7 @@ [1]: """ CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" @@ -61,7 +61,7 @@ [2]: """ CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" - FOR EACH ROW WHEN (("old"."_isDeleted" = "new"."_isDeleted") AND NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges())) BEGIN + FOR EACH ROW WHEN (("old"."_isDeleted" = "new"."_isDeleted") AND NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" @@ -81,7 +81,7 @@ [3]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_sync_engine" AFTER DELETE ON "childWithOnDeleteSetDefaults" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); END @@ -89,7 +89,7 @@ [4]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetDefaults_from_user" AFTER DELETE ON "childWithOnDeleteSetDefaults" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -101,7 +101,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); @@ -110,7 +110,7 @@ [5]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_sync_engine" AFTER DELETE ON "childWithOnDeleteSetNulls" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); END @@ -118,7 +118,7 @@ [6]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_childWithOnDeleteSetNulls_from_user" AFTER DELETE ON "childWithOnDeleteSetNulls" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -130,7 +130,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); @@ -139,7 +139,7 @@ [7]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_sync_engine" AFTER DELETE ON "modelAs" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); END @@ -147,7 +147,7 @@ [8]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelAs_from_user" AFTER DELETE ON "modelAs" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -159,7 +159,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); @@ -168,7 +168,7 @@ [9]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_sync_engine" AFTER DELETE ON "modelBs" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); END @@ -176,7 +176,7 @@ [10]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelBs_from_user" AFTER DELETE ON "modelBs" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -188,7 +188,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); @@ -197,7 +197,7 @@ [11]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_sync_engine" AFTER DELETE ON "modelCs" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); END @@ -205,7 +205,7 @@ [12]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_modelCs_from_user" AFTER DELETE ON "modelCs" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -217,7 +217,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); @@ -226,7 +226,7 @@ [13]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_sync_engine" AFTER DELETE ON "parents" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); END @@ -234,7 +234,7 @@ [14]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_parents_from_user" AFTER DELETE ON "parents" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -246,7 +246,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); @@ -255,7 +255,7 @@ [15]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_sync_engine" AFTER DELETE ON "reminderTags" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); END @@ -263,7 +263,7 @@ [16]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_user" AFTER DELETE ON "reminderTags" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -275,7 +275,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); @@ -284,7 +284,7 @@ [17]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_sync_engine" AFTER DELETE ON "remindersListAssets" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); END @@ -292,7 +292,7 @@ [18]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_user" AFTER DELETE ON "remindersListAssets" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -304,7 +304,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); @@ -313,7 +313,7 @@ [19]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_sync_engine" AFTER DELETE ON "remindersListPrivates" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); END @@ -321,7 +321,7 @@ [20]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_user" AFTER DELETE ON "remindersListPrivates" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -333,7 +333,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); @@ -342,7 +342,7 @@ [21]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_sync_engine" AFTER DELETE ON "remindersLists" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); END @@ -350,7 +350,7 @@ [22]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_user" AFTER DELETE ON "remindersLists" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -362,7 +362,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); @@ -371,7 +371,7 @@ [23]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_sync_engine" AFTER DELETE ON "reminders" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); END @@ -379,7 +379,7 @@ [24]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_user" AFTER DELETE ON "reminders" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -391,7 +391,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); @@ -400,7 +400,7 @@ [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_sync_engine" AFTER DELETE ON "tags" - FOR EACH ROW WHEN sqlitedata_icloud_syncEngineIsSynchronizingChanges() BEGIN + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN DELETE FROM "sqlitedata_icloud_metadata" WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); END @@ -408,7 +408,7 @@ [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_user" AFTER DELETE ON "tags" - FOR EACH ROW WHEN NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) BEGIN + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN WITH "rootShares" AS ( SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" FROM "sqlitedata_icloud_metadata" @@ -420,7 +420,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); @@ -441,7 +441,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' @@ -464,7 +464,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' @@ -487,7 +487,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelAs', NULL, NULL @@ -510,7 +510,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' @@ -533,7 +533,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' @@ -556,7 +556,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'parents', NULL, NULL @@ -579,7 +579,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminderTags', NULL, NULL @@ -602,7 +602,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' @@ -625,7 +625,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' @@ -648,7 +648,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' @@ -671,7 +671,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersLists', NULL, NULL @@ -694,7 +694,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."title", 'tags', NULL, NULL @@ -717,7 +717,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); @@ -738,7 +738,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); @@ -759,7 +759,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); @@ -780,7 +780,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); @@ -801,7 +801,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); @@ -822,7 +822,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); @@ -843,7 +843,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); @@ -864,7 +864,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); @@ -885,7 +885,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); @@ -906,7 +906,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); @@ -927,7 +927,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); @@ -948,7 +948,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); @@ -969,7 +969,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' @@ -992,7 +992,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' @@ -1015,7 +1015,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelAs', NULL, NULL @@ -1038,7 +1038,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' @@ -1061,7 +1061,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' @@ -1084,7 +1084,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'parents', NULL, NULL @@ -1107,7 +1107,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminderTags', NULL, NULL @@ -1130,7 +1130,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' @@ -1153,7 +1153,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' @@ -1176,7 +1176,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' @@ -1199,7 +1199,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersLists', NULL, NULL @@ -1222,7 +1222,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT (sqlitedata_icloud_syncEngineIsSynchronizingChanges()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."title", 'tags', NULL, NULL From d8e9ed653b4c57c7e35561c1606bf9a52efa9117 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 17:00:05 -0700 Subject: [PATCH 525/581] wip --- Sources/SQLiteData/CloudKit/ForeignKey.swift | 2 +- .../SQLiteData/CloudKit/Metadatabase.swift | 14 ++++---- .../SQLiteData/CloudKit/SQLiteSchema.swift | 2 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 32 +++++++++---------- .../SQLiteData/CloudKit/SyncMetadata.swift | 4 +-- Sources/SQLiteData/CloudKit/TableInfo.swift | 2 +- Sources/SQLiteData/CloudKit/Triggers.swift | 22 ++++++------- .../CloudKit/UnsyncedRecordID.swift | 7 +--- .../CloudKitTests/SyncEngineTests.swift | 2 +- 9 files changed, 41 insertions(+), 46 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/ForeignKey.swift b/Sources/SQLiteData/CloudKit/ForeignKey.swift index 4c198433..4dd5461a 100644 --- a/Sources/SQLiteData/CloudKit/ForeignKey.swift +++ b/Sources/SQLiteData/CloudKit/ForeignKey.swift @@ -42,7 +42,7 @@ static func all( _ tableName: String ) -> some StructuredQueriesCore.Statement { - SQLQueryExpression( + #sql( """ SELECT \(columns) FROM pragma_foreign_key_list(\(bind: tableName)) AS "foreign_keys" diff --git a/Sources/SQLiteData/CloudKit/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Metadatabase.swift index 6327817a..46538ce2 100644 --- a/Sources/SQLiteData/CloudKit/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Metadatabase.swift @@ -50,7 +50,7 @@ migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create Metadata Tables") { db in - try SQLQueryExpression( + try #sql( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( "recordPrimaryKey" TEXT NOT NULL, @@ -72,21 +72,21 @@ """ ) .execute(db) - try SQLQueryExpression( + try #sql( """ CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("parentRecordName") """ ) .execute(db) - try SQLQueryExpression( + try #sql( """ CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("isShared") """ ) .execute(db) - try SQLQueryExpression( + try #sql( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( "tableName" TEXT NOT NULL PRIMARY KEY, @@ -96,7 +96,7 @@ """ ) .execute(db) - try SQLQueryExpression( + try #sql( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( "scope" TEXT NOT NULL PRIMARY KEY, @@ -105,7 +105,7 @@ """ ) .execute(db) - try SQLQueryExpression( + try #sql( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( "recordName" TEXT NOT NULL, @@ -118,7 +118,7 @@ .execute(db) } migrator.registerMigration("Create PendingRecordZoneChanges Table") { db in - try SQLQueryExpression( + try #sql( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( "pendingRecordZoneChange" BLOB NOT NULL diff --git a/Sources/SQLiteData/CloudKit/SQLiteSchema.swift b/Sources/SQLiteData/CloudKit/SQLiteSchema.swift index 43c6f72c..507b249d 100644 --- a/Sources/SQLiteData/CloudKit/SQLiteSchema.swift +++ b/Sources/SQLiteData/CloudKit/SQLiteSchema.swift @@ -23,7 +23,7 @@ struct SQLiteSchema: QueryDecodable, QueryRepresentable { } static var all: some StructuredQueriesCore.Statement { - SQLQueryExpression( + #sql( """ SELECT \(columns) FROM "sqlite_schema" """, diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 1426b4ab..7cf1f558 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -200,7 +200,7 @@ nonisolated package func setUpSyncEngine() throws { try userDatabase.write { db in let attachedMetadatabasePath: String? = - try SQLQueryExpression( + try #sql( """ SELECT "file" FROM pragma_database_list() @@ -226,7 +226,7 @@ } } else { - try SQLQueryExpression( + try #sql( """ ATTACH DATABASE \(bind: metadatabase.path) AS \(quote: .sqliteDataCloudKitSchemaName) """ @@ -418,7 +418,7 @@ changedColumnNames: changedColumns ) try await userDatabase.write { db in - try SQLQueryExpression(query).execute(db) + try #sql(query).execute(db) } } } @@ -751,7 +751,7 @@ try userDatabase.read { db in try T .where { - SQLQueryExpression("\($0.primaryKey) = \(bind: metadata.recordPrimaryKey)") + #sql("\($0.primaryKey) = \(bind: metadata.recordPrimaryKey)") } .fetchOne(db) } @@ -1036,7 +1036,7 @@ try T .where { $0.primaryKey.in( - recordPrimaryKeys.map { SQLQueryExpression("\(bind: $0)") } + recordPrimaryKeys.map { #sql("\(bind: $0)") } ) } .delete() @@ -1208,7 +1208,7 @@ switch foreignKey.onDelete { case .cascade: try T - .where { SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") } + .where { #sql("\($0.primaryKey) = \(bind: recordPrimaryKey)") } .delete() .execute(db) case .restrict: @@ -1223,7 +1223,7 @@ }) else { return } let defaultValue = columnInfo.defaultValue ?? "NULL" - try SQLQueryExpression( + try #sql( """ UPDATE \(T.self) SET \(quote: foreignKey.from, delimiter: .identifier) = (\(raw: defaultValue)) @@ -1233,7 +1233,7 @@ .execute(db) break case .setNull: - try SQLQueryExpression( + try #sql( """ UPDATE \(T.self) SET \(quote: foreignKey.from, delimiter: .identifier) = NULL @@ -1267,7 +1267,7 @@ } catch let error as CKError where error.code == .unknownItem { try await userDatabase.write { db in try T - .where { SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") } + .where { #sql("\($0.primaryKey) = \(bind: recordPrimaryKey)") } .delete() .execute(db) } @@ -1393,7 +1393,7 @@ var columnNames = T.TableColumns.writableColumns.map(\.name) if !force, let metadata, let allFields = metadata._lastKnownServerRecordAllFields { let row = try userDatabase.read { db in - try T.find(SQLQueryExpression("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) + try T.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) } guard let row else { @@ -1419,7 +1419,7 @@ try userDatabase.write { db in do { let query = upsert(T.self, record: serverRecord, columnNames: columnNames) - try SQLQueryExpression(query).execute(db) + try #sql(query).execute(db) try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db) try SyncMetadata .where { $0.recordName.eq(serverRecord.recordID.recordName) } @@ -1759,7 +1759,7 @@ ) } - let databasePath = try SQLQueryExpression( + let databasePath = try #sql( """ SELECT "file" FROM pragma_database_list() """, @@ -1785,9 +1785,9 @@ withIntermediateDirectories: true ) _ = try DatabasePool(path: path).write { db in - try SQLQueryExpression("SELECT 1").execute(db) + try #sql("SELECT 1").execute(db) } - try SQLQueryExpression( + try #sql( """ ATTACH DATABASE \(bind: path) AS \(quote: .sqliteDataCloudKitSchemaName) """ @@ -1860,7 +1860,7 @@ for table in tables { let columnsWithUniqueConstraints = - try SQLQueryExpression( + try #sql( """ SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) WHERE "unique" = 1 AND "origin" <> 'pk' @@ -1903,7 +1903,7 @@ let tableDependencies = try userDatabase.read { db in var dependencies: [HashablePrimaryKeyedTableType: [any PrimaryKeyedTable.Type]] = [:] for table in tables { - let toTables = try SQLQueryExpression( + let toTables = try #sql( """ SELECT "table" FROM pragma_foreign_key_list(\(quote: table.tableName, delimiter: .text)) """, diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index dbcf9b61..2ecaf3e0 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -134,7 +134,7 @@ extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where { SyncMetadata.where { - SQLQueryExpression( + #sql( """ \($0.recordPrimaryKey) = \(PrimaryKey(queryOutput: primaryKey)) \ AND \($0.recordType) = \(bind: tableName) @@ -168,7 +168,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTableDefinition { var _recordName: some QueryExpression { - SQLQueryExpression("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") + #sql("\(primaryKey) || ':' || \(quote: QueryValue.tableName, delimiter: .text)") } } #endif diff --git a/Sources/SQLiteData/CloudKit/TableInfo.swift b/Sources/SQLiteData/CloudKit/TableInfo.swift index 28e53335..462615b8 100644 --- a/Sources/SQLiteData/CloudKit/TableInfo.swift +++ b/Sources/SQLiteData/CloudKit/TableInfo.swift @@ -28,7 +28,7 @@ package struct TableInfo: Codable, Hashable, QueryDecodable, QueryRepresentable static func all( _ tableName: String ) -> some StructuredQueriesCore.Statement { - SQLQueryExpression( + #sql( """ SELECT \(columns) FROM pragma_table_info(\(bind: tableName)) """, diff --git a/Sources/SQLiteData/CloudKit/Triggers.swift b/Sources/SQLiteData/CloudKit/Triggers.swift index 8dfaaf4f..eb8eda18 100644 --- a/Sources/SQLiteData/CloudKit/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Triggers.swift @@ -24,7 +24,7 @@ checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) SyncMetadata .where { - $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) + $0.recordPrimaryKey.eq(#sql("\(old.primaryKey)")) && $0.recordType.eq(tableName) } .update { $0._isDeleted = true } @@ -66,7 +66,7 @@ checkWritePermissions(alias: old, parentForeignKey: parentForeignKey) SyncMetadata .where { - $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) + $0.recordPrimaryKey.eq(#sql("\(old.primaryKey)")) && $0.recordType.eq(tableName) } .update { $0._isDeleted = true } @@ -83,7 +83,7 @@ after: .delete { old in SyncMetadata .where { - $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) + $0.recordPrimaryKey.eq(#sql("\(old.primaryKey)")) && $0.recordType.eq(tableName) } .delete() @@ -108,10 +108,10 @@ ($0.recordPrimaryKey, $0.recordType, $0.parentRecordPrimaryKey, $0.parentRecordType) } select: { Values( - SQLQueryExpression("\(new.primaryKey)"), + #sql("\(new.primaryKey)"), T.tableName, - SQLQueryExpression(parentRecordPrimaryKey), - SQLQueryExpression(parentRecordType) + #sql(parentRecordPrimaryKey), + #sql(parentRecordType) ) } onConflict: { ($0.recordPrimaryKey, $0.recordType) @@ -213,7 +213,7 @@ } private func isUpdatingWithServerRecord() -> SQLQueryExpression { - SQLQueryExpression("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") + #sql("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") } private func parentFields( @@ -238,8 +238,8 @@ return With { SyncMetadata .where { - $0.recordPrimaryKey.is(SQLQueryExpression(parentRecordPrimaryKey)) - && $0.recordType.is(SQLQueryExpression(parentRecordType)) + $0.recordPrimaryKey.is(#sql(parentRecordPrimaryKey)) + && $0.recordType.is(#sql(parentRecordType)) } .select { RootShare.Columns(parentRecordName: $0.parentRecordName, share: $0.share) } .union( @@ -253,7 +253,7 @@ } query: { RootShare .select { _ in - SQLQueryExpression( + #sql( "RAISE(ABORT, \(quote: SyncEngine.writePermissionError, delimiter: .text))", as: Never.self ) @@ -261,7 +261,7 @@ .where { !SyncEngine.isSynchronizingChanges() && $0.parentRecordName.is(nil) - && !SQLQueryExpression( + && !#sql( "\(raw: String.sqliteDataCloudKitSchemaName)_hasPermission(\($0.share))" ) } diff --git a/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift b/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift index 12d769b4..f24a3e04 100644 --- a/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift +++ b/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift @@ -30,12 +30,7 @@ } .joined(separator: ", ") return Self.where { - SQLQueryExpression( - """ - (\($0.recordName), \($0.zoneName), \($0.ownerName)) \ - IN (\(condition)) - """ - ) + #sql("(\($0.recordName), \($0.zoneName), \($0.ownerName)) IN (\(condition))") } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift index cb278333..8131a809 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift @@ -33,7 +33,7 @@ ) try await syncEngine.userDatabase.read { db in - try SQLQueryExpression( + try #sql( """ SELECT 1 FROM "sqlitedata_icloud_metadata" """ From 47f3521060026d0d2aa8d6edce129fcbb83e940b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 17:11:49 -0700 Subject: [PATCH 526/581] wip --- .../CloudKit/CloudKitFunctions.swift | 11 +++++++ Sources/SQLiteData/CloudKit/SyncEngine.swift | 32 ++----------------- Sources/SQLiteData/CloudKit/Triggers.swift | 4 +-- 3 files changed, 14 insertions(+), 33 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift b/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift index 2d85f7d7..667987cc 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift @@ -1,4 +1,5 @@ #if canImport(CloudKit) + import CloudKit import Foundation @DatabaseFunction("sqlitedata_icloud_datetime") @@ -7,6 +8,16 @@ return now } + @DatabaseFunction( + "sqlitedata_icloud_hasPermission", + as: ((CKShare?.SystemFieldsRepresentation) -> Bool).self + ) + func hasPermission(_ share: CKShare?) -> Bool { + guard let share else { return true } + return share.publicPermission == .readWrite + || share.currentUserParticipant?.permission == .readWrite + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @DatabaseFunction("sqlitedata_icloud_syncEngineIsSynchronizingChanges") func syncEngineIsSynchronizingChanges() -> Bool { diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 7cf1f558..471b04f7 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -237,7 +237,7 @@ db.add(function: $syncEngineIsSynchronizingChanges) db.add(function: .didUpdate(syncEngine: self)) db.add(function: .didDelete(syncEngine: self)) - db.add(function: .hasPermission) + db.add(function: $hasPermission) for trigger in SyncMetadata.callbackTriggers { try trigger.execute(db) @@ -434,7 +434,7 @@ for trigger in SyncMetadata.callbackTriggers.reversed() { try trigger.drop().execute(db) } - db.remove(function: .hasPermission) + db.remove(function: $hasPermission) db.remove(function: .didDelete(syncEngine: self)) db.remove(function: .didUpdate(syncEngine: self)) db.remove(function: $syncEngineIsSynchronizingChanges) @@ -1596,34 +1596,6 @@ } } -// fileprivate static var datetime: Self { -// Self(.sqliteDataCloudKitSchemaName + "_datetime", argumentCount: 0) { _ in -// @Dependency(\.datetime.now) var now -// return now.formatted( -// .iso8601 -// .year().month().day() -// .dateTimeSeparator(.space) -// .time(includingFractionalSeconds: true) -// ) -// } -// } - - fileprivate static var hasPermission: Self { - Self(.sqliteDataCloudKitSchemaName + "_hasPermission", argumentCount: 1) { arguments in - let share = try Data.fromDatabaseValue(arguments[0]).flatMap { - let coder = try NSKeyedUnarchiver(forReadingFrom: $0) - coder.requiresSecureCoding = true - return CKShare(coder: coder) - } - guard let share - else { return true } - let hasPermission = - share.publicPermission == .readWrite - || share.currentUserParticipant?.permission == .readWrite - return hasPermission - } - } - private convenience init( _ name: String, function: @escaping @Sendable (String, CKRecordZone.ID?, CKShare?) -> Void diff --git a/Sources/SQLiteData/CloudKit/Triggers.swift b/Sources/SQLiteData/CloudKit/Triggers.swift index eb8eda18..972fc924 100644 --- a/Sources/SQLiteData/CloudKit/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Triggers.swift @@ -261,9 +261,7 @@ .where { !SyncEngine.isSynchronizingChanges() && $0.parentRecordName.is(nil) - && !#sql( - "\(raw: String.sqliteDataCloudKitSchemaName)_hasPermission(\($0.share))" - ) + && $hasPermission($0.share) } } } From 7f003b4c0d1fa7814306ad8215b1475f2d5c2e07 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 2 Sep 2025 19:50:52 -0500 Subject: [PATCH 527/581] fix test --- Sources/SQLiteData/CloudKit/Triggers.swift | 2 +- .../CloudKitTests/TriggerTests.swift | 96 +++++++++---------- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Triggers.swift b/Sources/SQLiteData/CloudKit/Triggers.swift index 972fc924..c7fcf3ec 100644 --- a/Sources/SQLiteData/CloudKit/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Triggers.swift @@ -261,7 +261,7 @@ .where { !SyncEngine.isSynchronizingChanges() && $0.parentRecordName.is(nil) - && $hasPermission($0.share) + && !$hasPermission($0.share) } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index d189c4f4..c0e8ad90 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -101,7 +101,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); @@ -130,7 +130,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); @@ -159,7 +159,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); @@ -188,7 +188,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); @@ -217,7 +217,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); @@ -246,7 +246,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); @@ -275,7 +275,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); @@ -304,7 +304,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); @@ -333,7 +333,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); @@ -362,7 +362,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); @@ -391,7 +391,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); @@ -420,7 +420,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); @@ -441,7 +441,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' @@ -464,7 +464,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' @@ -487,7 +487,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelAs', NULL, NULL @@ -510,7 +510,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' @@ -533,7 +533,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' @@ -556,7 +556,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'parents', NULL, NULL @@ -579,7 +579,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminderTags', NULL, NULL @@ -602,7 +602,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' @@ -625,7 +625,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' @@ -648,7 +648,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' @@ -671,7 +671,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersLists', NULL, NULL @@ -694,7 +694,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."title", 'tags', NULL, NULL @@ -717,7 +717,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); @@ -738,7 +738,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); @@ -759,7 +759,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); @@ -780,7 +780,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); @@ -801,7 +801,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); @@ -822,7 +822,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); @@ -843,7 +843,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); @@ -864,7 +864,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); @@ -885,7 +885,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); @@ -906,7 +906,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); @@ -927,7 +927,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); @@ -948,7 +948,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); @@ -969,7 +969,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetDefaults', "new"."parentID", 'parents' @@ -992,7 +992,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'childWithOnDeleteSetNulls', "new"."parentID", 'parents' @@ -1015,7 +1015,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelAs', NULL, NULL @@ -1038,7 +1038,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelBs', "new"."modelAID", 'modelAs' @@ -1061,7 +1061,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelCs', "new"."modelBID", 'modelBs' @@ -1084,7 +1084,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'parents', NULL, NULL @@ -1107,7 +1107,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminderTags', NULL, NULL @@ -1130,7 +1130,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminders', "new"."remindersListID", 'remindersLists' @@ -1153,7 +1153,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListAssets', "new"."remindersListID", 'remindersLists' @@ -1176,7 +1176,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersListPrivates', "new"."remindersListID", 'remindersLists' @@ -1199,7 +1199,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersLists', NULL, NULL @@ -1222,7 +1222,7 @@ ) SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') FROM "rootShares" - WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); + WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" ("recordPrimaryKey", "recordType", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."title", 'tags', NULL, NULL From cbe51620d574029da6e96e8441e3cc315c0aa297 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 2 Sep 2025 22:48:47 -0700 Subject: [PATCH 528/581] wip --- .../{ => Internal}/CloudContainer.swift | 0 .../{ => Internal}/CloudDatabase.swift | 0 .../{ => Internal}/CloudKitFunctions.swift | 0 .../CloudKit/{ => Internal}/ForeignKey.swift | 0 .../CloudKit/{ => Internal}/Logging.swift | 0 .../{ => Internal}/Metadatabase.swift | 0 .../PendingRecordZoneChange.swift | 0 .../CloudKit/{ => Internal}/RecordType.swift | 7 +--- .../CloudKit/Internal/SQLiteSchema.swift | 8 ++++ .../{ => Internal}/StateSerialization.swift | 0 .../{ => Internal}/SyncEngine.Event.swift | 2 +- .../SyncEngineProtocol+Live.swift | 0 .../{ => Internal}/SyncEngineProtocol.swift | 0 .../CloudKit/{ => Internal}/TableInfo.swift | 0 .../CloudKit/{ => Internal}/Triggers.swift | 0 .../{ => Internal}/UnsyncedRecordID.swift | 0 .../SQLiteData/CloudKit/SQLiteSchema.swift | 39 ------------------- 17 files changed, 11 insertions(+), 45 deletions(-) rename Sources/SQLiteData/CloudKit/{ => Internal}/CloudContainer.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/CloudDatabase.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/CloudKitFunctions.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/ForeignKey.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/Logging.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/Metadatabase.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/PendingRecordZoneChange.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/RecordType.swift (78%) create mode 100644 Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift rename Sources/SQLiteData/CloudKit/{ => Internal}/StateSerialization.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/SyncEngine.Event.swift (99%) rename Sources/SQLiteData/CloudKit/{ => Internal}/SyncEngineProtocol+Live.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/SyncEngineProtocol.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/TableInfo.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/Triggers.swift (100%) rename Sources/SQLiteData/CloudKit/{ => Internal}/UnsyncedRecordID.swift (100%) delete mode 100644 Sources/SQLiteData/CloudKit/SQLiteSchema.swift diff --git a/Sources/SQLiteData/CloudKit/CloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/CloudContainer.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/CloudContainer.swift rename to Sources/SQLiteData/CloudKit/Internal/CloudContainer.swift diff --git a/Sources/SQLiteData/CloudKit/CloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/CloudDatabase.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/CloudDatabase.swift rename to Sources/SQLiteData/CloudKit/Internal/CloudDatabase.swift diff --git a/Sources/SQLiteData/CloudKit/CloudKitFunctions.swift b/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/CloudKitFunctions.swift rename to Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift diff --git a/Sources/SQLiteData/CloudKit/ForeignKey.swift b/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/ForeignKey.swift rename to Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift diff --git a/Sources/SQLiteData/CloudKit/Logging.swift b/Sources/SQLiteData/CloudKit/Internal/Logging.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/Logging.swift rename to Sources/SQLiteData/CloudKit/Internal/Logging.swift diff --git a/Sources/SQLiteData/CloudKit/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/Metadatabase.swift rename to Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift diff --git a/Sources/SQLiteData/CloudKit/PendingRecordZoneChange.swift b/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/PendingRecordZoneChange.swift rename to Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift diff --git a/Sources/SQLiteData/CloudKit/RecordType.swift b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift similarity index 78% rename from Sources/SQLiteData/CloudKit/RecordType.swift rename to Sources/SQLiteData/CloudKit/Internal/RecordType.swift index 78789acd..5e9e8a93 100644 --- a/Sources/SQLiteData/CloudKit/RecordType.swift +++ b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift @@ -15,11 +15,8 @@ extension RecordType: CustomDumpReflectable { self, children: [ ("tableName", tableName as Any), - ("schema", schema as Any), - ( - "tableInfo", - tableInfo.sorted(by: { $0.name < $1.name }) as Any - ), + ("schema", schema), + ("tableInfo", tableInfo.sorted(by: { $0.name < $1.name })), ], displayStyle: .struct ) diff --git a/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift new file mode 100644 index 00000000..7852d6be --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift @@ -0,0 +1,8 @@ +@Table("sqlite_schema") +struct SQLiteSchema { + let type: String + let name: String + @Column("tbl_name") + let tableName: String + let sql: String? +} diff --git a/Sources/SQLiteData/CloudKit/StateSerialization.swift b/Sources/SQLiteData/CloudKit/Internal/StateSerialization.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/StateSerialization.swift rename to Sources/SQLiteData/CloudKit/Internal/StateSerialization.swift diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.Event.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift similarity index 99% rename from Sources/SQLiteData/CloudKit/SyncEngine.Event.swift rename to Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift index 74d8de9b..230aac02 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.Event.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift @@ -82,7 +82,7 @@ } } - public var description: String { + package var description: String { switch self { case .stateUpdate: "stateUpdate" case .accountChange: "accountChange" diff --git a/Sources/SQLiteData/CloudKit/SyncEngineProtocol+Live.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/SyncEngineProtocol+Live.swift rename to Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift diff --git a/Sources/SQLiteData/CloudKit/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/SyncEngineProtocol.swift rename to Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift diff --git a/Sources/SQLiteData/CloudKit/TableInfo.swift b/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/TableInfo.swift rename to Sources/SQLiteData/CloudKit/Internal/TableInfo.swift diff --git a/Sources/SQLiteData/CloudKit/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/Triggers.swift rename to Sources/SQLiteData/CloudKit/Internal/Triggers.swift diff --git a/Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift b/Sources/SQLiteData/CloudKit/Internal/UnsyncedRecordID.swift similarity index 100% rename from Sources/SQLiteData/CloudKit/UnsyncedRecordID.swift rename to Sources/SQLiteData/CloudKit/Internal/UnsyncedRecordID.swift diff --git a/Sources/SQLiteData/CloudKit/SQLiteSchema.swift b/Sources/SQLiteData/CloudKit/SQLiteSchema.swift deleted file mode 100644 index 507b249d..00000000 --- a/Sources/SQLiteData/CloudKit/SQLiteSchema.swift +++ /dev/null @@ -1,39 +0,0 @@ -import StructuredQueriesCore - -struct SQLiteSchema: QueryDecodable, QueryRepresentable { - typealias QueryValue = Self - - let type: String - let name: String - let tableName: String - let sql: String? - - init(decoder: inout some QueryDecoder) throws { - guard - let type = try decoder.decode(String.self), - let name = try decoder.decode(String.self), - let tableName = try decoder.decode(String.self) - else { - throw QueryDecodingError.missingRequiredColumn - } - self.type = type - self.name = name - self.tableName = tableName - self.sql = try decoder.decode(String.self) - } - - static var all: some StructuredQueriesCore.Statement { - #sql( - """ - SELECT \(columns) FROM "sqlite_schema" - """, - as: Self.self - ) - } - - static var columns: QueryFragment { - """ - "type", "name", "tbl_name", "sql" - """ - } -} From a669450218e5146a007b5d7da618a94d442cf8ff Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:50:04 -0500 Subject: [PATCH 529/581] More docs (#154) * More docs * wip * wip * wip * wip * Format SQLiteData entry in README.md table * wip * Update Sources/SQLiteData/CloudKit/CloudKitSharing.swift * Update Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md * Update Sources/SQLiteData/CloudKit/SyncMetadata.swift * Format SQLiteData performance table for clarity --------- Co-authored-by: Stephen Celis --- Examples/Examples.xcodeproj/project.pbxproj | 16 +- Examples/Reminders/RemindersDetail.swift | 1 + Package.resolved | 6 +- Package.swift | 2 +- Package@swift-6.0.swift | 2 +- README.md | 26 +- .../SQLiteData/CloudKit/CloudKitSharing.swift | 48 +++- .../CloudKit/DefaultSyncEngine.swift | 37 +++ .../IdentifierStringConvertible.swift | 1 + .../CloudKit/Internal/MockCloudDatabase.swift | 8 - .../CloudKit/Internal/Triggers.swift | 145 ++++++----- Sources/SQLiteData/CloudKit/SyncEngine.swift | 135 +++++----- .../SQLiteData/CloudKit/SyncMetadata.swift | 7 +- .../Documentation.docc/Articles/CloudKit.md | 22 +- .../Articles/CloudKitSharing.md | 5 + .../Articles/ComparisonWithSwiftData.md | 6 +- .../Articles/DynamicQueries.md | 3 + .../Documentation.docc/Articles/Fetching.md | 22 +- .../Articles/PreparingDatabase.md | 78 +++--- .../Documentation.docc/SQLiteData.md | 43 ++-- .../CloudKitTests/AccountLifecycleTests.swift | 1 + .../FetchRecordZoneChangesTests.swift | 233 ++++++++++++++++++ .../MockCloudDatabaseTests.swift | 16 -- .../SyncEngineLifecycleTests.swift | 9 + .../CloudKitTests/TriggerTests.swift | 14 +- 25 files changed, 613 insertions(+), 273 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index e36be4b8..2db7d786 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -13,11 +13,11 @@ CA5E47072DECEF0F0069E0F8 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */; }; CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */; }; CA5E470B2DECF0280069E0F8 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */; }; + CA6A1D242E68A0A600604D6A /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA6A1D232E68A0A600604D6A /* SQLiteData */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8A2E02176700FB20F8 /* SharingGRDB */; }; - DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8C2E02177200FB20F8 /* SharingGRDB */; }; DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8E2E02177900FB20F8 /* SharingGRDB */; }; DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCF267382D48437300B680BE /* SwiftUINavigation */; }; /* End PBXBuildFile section */ @@ -163,8 +163,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA6A1D242E68A0A600604D6A /* SQLiteData in Frameworks */, CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, - DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */, CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -337,7 +337,7 @@ packageProductDependencies = ( CA14DBC82DA884C400E36852 /* CasePaths */, CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */, - DCD9AC8C2E02177200FB20F8 /* SharingGRDB */, + CA6A1D232E68A0A600604D6A /* SQLiteData */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -1076,6 +1076,11 @@ package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesTestSupport; }; + CA6A1D232E68A0A600604D6A /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; + productName = SQLiteData; + }; CAD001862D874F1F00FA977A /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; @@ -1095,11 +1100,6 @@ isa = XCSwiftPackageProductDependency; productName = SharingGRDB; }; - DCD9AC8C2E02177200FB20F8 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; - productName = SharingGRDB; - }; DCD9AC8E2E02177900FB20F8 /* SharingGRDB */ = { isa = XCSwiftPackageProductDependency; package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 4e8a534f..e4327c1b 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,5 +1,6 @@ import CasePaths import CloudKit +import Sharing import SQLiteData import SwiftUI import SwiftUINavigation diff --git a/Package.resolved b/Package.resolved index 40558cc0..d2942708 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bf7f65a97bc0744011b5a84033dae2413dabad76028eccac59d6837fd798cf8a", + "originHash" : "3b49a4e324dfd736adfe38cb30f7c3a771fb77d8faee549e703df3e8b4f7f8fd", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "14f79c6dad72e385c564c66dbe333522a11eaa48", - "version" : "0.16.0" + "revision" : "adad5c6c5abe0c62f93c573de5be071043f621a8", + "version" : "0.17.0" } }, { diff --git a/Package.swift b/Package.swift index ce3e8b34..9c740c67 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package( url: "https://github.com/pointfreeco/swift-structured-queries", - from: "0.16.0", + from: "0.17.0", traits: [ .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SQLiteDataTagged"])) ] diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 499b0acb..b1241a7f 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -27,7 +27,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.16.0"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.17.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ diff --git a/README.md b/README.md index ed1db5e5..0f0906eb 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ var items: [Item] @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true var notes = "" @@ -178,6 +178,9 @@ var items +@FetchAll(Item.order(by: \.isInStock)) +var items + @FetchOne(Item.count()) var itemsCount = 0 @@ -198,6 +201,9 @@ var items: [Item] }) var items: [Item] +// No @Query equivalent of ordering +// by boolean column. + // No @Query equivalent of counting // entries in database without loading // all entries. @@ -303,14 +309,16 @@ See the following benchmarks against taste of how it compares: ``` -Orders.fetchAll setup rampup duration - SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 - Lighter (1.4.10) 0 0.164 8.059 - SQLiteData (0.2.0) 0 0.172 8.511 - GRDB (7.4.1, manual decoding) 0 0.376 18.819 - SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 - SQLite.swift (0.15.3, Codable) 0 0.863 43.261 - GRDB (7.4.1, Codable) 0.002 1.07 53.326 +Orders.fetchAll setup rampup duration + SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 + Lighter (1.4.10) 0 0.164 8.059 +┌──────────────────────────────────────────────────────────────────┐ +│ SQLiteData (1.0.0) 0 0.172 8.511 │ +└──────────────────────────────────────────────────────────────────┘ + GRDB (7.4.1, manual decoding) 0 0.376 18.819 + SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 + SQLite.swift (0.15.3, Codable) 0 0.863 43.261 + GRDB (7.4.1, Codable) 0.002 1.07 53.326 ``` ## SQLite knowledge required diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index d1cc32f7..54bbed07 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -7,6 +7,9 @@ import UIKit #endif + /// A shared record that can be used to present a ``CloudSharingView``. + /// + /// See for more information., @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) public struct SharedRecord: Hashable, Identifiable, Sendable { let container: any CloudContainer @@ -43,7 +46,26 @@ "The record could not be shared." } } - + + /// Shares a record in CloudKit. + /// + /// This method will thrown an error if: + /// + /// * The table the `record` belongs to is not synchronized to CloudKit. + /// * The `record` has any foreign keys. Only root records are shareable in CloudKit. + /// * The table the `record` belongs to is a "private" table as determined by the + /// [`SyncEngine` initializer](). + /// * The `record` is being shared before it has been synchronized to CloudKit. + /// * Any of the CloudKit APIs invoked throw an error. + /// + /// The value returned from this method can be used to present a ``CloudSharingView`` which + /// allows the user to send a share URL to another user. + /// + /// - Parameters: + /// - record: The record to be shared on CloudKit. + /// - configure: A trailing closure that can customize the `CKShare` sent to CloudKit. See + /// [Apple's documentation](https://developer.apple.com/documentation/cloudkit/ckshare/systemfieldkey) + /// for more info on what can be configured. public func share( record: T, configure: @Sendable (CKShare) -> Void @@ -56,7 +78,8 @@ recordPrimaryKey: record.primaryKey.rawIdentifier, reason: .recordTableNotSynchronized, debugDescription: """ - Table is not shareable: table type not passed to 'tables' parameter of 'SyncEngine.init'. + Table is not shareable: table type not passed to 'tables' parameter of \ + 'SyncEngine.init'. """ ) } @@ -116,7 +139,6 @@ return try await container.database(for: rootRecord.recordID) .record(for: shareRecordID) as? CKShare } catch let error as CKError where error.code == .unknownItem { - reportIssue("This would have been a problem before") return nil } } @@ -133,8 +155,6 @@ ) configure(sharedRecord) - // TODO: We are getting an "client oplock error updating record" error in the logs when - // creating new shares / editing existing shares. _ = try await container.privateCloudDatabase.modifyRecords( saving: [sharedRecord, rootRecord], deleting: [] @@ -173,13 +193,21 @@ ) try result?.deleteResults.values.forEach { _ = try $0.get() } } - + + /// Accepts a shared record. + /// + /// This method should be invoked from various delegate methods on the scene delegate of the + /// app. See for more info. public func acceptShare(metadata: CKShare.Metadata) async throws { try await acceptShare(metadata: ShareMetadata(rawValue: metadata)) } } - #if canImport(UIKit) && !os(watchOS) +#if canImport(UIKit) && !os(watchOS) + /// A view that presents standard screens for adding and removing people from a CloudKit share \ + /// record. + /// + /// See for more info. @available(iOS 17, macOS 14, tvOS 17, *) public struct CloudSharingView: UIViewControllerRepresentable { let sharedRecord: SharedRecord @@ -204,8 +232,8 @@ self.syncEngine = syncEngine } - public func makeCoordinator() -> CloudSharingDelegate { - CloudSharingDelegate( + public func makeCoordinator() -> _CloudSharingDelegate { + _CloudSharingDelegate( share: sharedRecord.share, didFinish: didFinish, didStopSharing: didStopSharing, @@ -231,7 +259,7 @@ } @available(iOS 17, macOS 14, tvOS 17, *) - public final class CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { + public final class _CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { let share: CKShare let didFinish: (Result) -> Void let didStopSharing: () -> Void diff --git a/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift b/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift index 7fe1a580..93b34516 100644 --- a/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift @@ -5,6 +5,43 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension DependencyValues { + /// The default sync engine used by the application. + /// + /// Configure this as early as possible in your app's lifetime, like the app entry point in + /// SwiftUI, using `prepareDependencies`: + /// + /// ```swift + /// import SQLiteData + /// import SwiftUI + /// + /// ```swift + /// @main + /// struct MyApp: App { + /// init() { + /// prepareDependencies { + /// $0.defaultDatabase = try! appDatabase() + /// $0.defaultSyncEngine = SyncEngine( + /// for: $0.defaultDatabase, + /// tables: Item.self + /// ) + /// } + /// } + /// // ... + /// } + /// ``` + /// + /// > Note: You can only prepare the default sync engine a single time in the lifetime of + /// > your app. Attempting to do so more than once will produce a runtime warning. + /// + /// Once configured, access the default sync engine anywhere using `@Dependency`: + /// + /// ```swift + /// @Dependency(\.defaultSyncEngine) var syncEngine + /// + /// syncEngine.acceptShare(metadata: metadata) + /// ``` + /// + /// See for more info. public var defaultSyncEngine: SyncEngine { get { self[SyncEngine.self] } set { self[SyncEngine.self] = newValue } diff --git a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift index b7b2639b..54243d39 100644 --- a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift +++ b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift @@ -1,5 +1,6 @@ import Foundation +/// A type that can be represented by a string identifier. public protocol IdentifierStringConvertible { init?(rawIdentifier: String) var rawIdentifier: String { get } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 6232cf7f..0593f837 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -194,14 +194,6 @@ package final class MockCloudDatabase: CloudDatabase { // We are trying to save a record that does not have a change tag yet also already // exists in the DB. This means the user has created a new CKRecord from scratch, // giving it a new identity, rather than leveraging an existing CKRecord. - reportIssue( - """ - A new identity was created for an existing 'CKRecord' \ - ('\(existingRecord.recordID.recordName)'). Rather than creating \ - 'CKRecord' from scratch for an existing record, use the database to fetch the \ - current record. - """ - ) saveResults[recordToSave.recordID] = .failure( CKError( .serverRejectedRequest, diff --git a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift index c7fcf3ec..0cf5a6e6 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift @@ -51,6 +51,7 @@ ifNotExists: true, after: .update { _, new in checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) + // TODO: change to update? SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } ) @@ -125,97 +126,74 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - static var callbackTriggers: [TemporaryTrigger] { + static func callbackTriggers(for syncEngine: SyncEngine) -> [TemporaryTrigger] { [ - afterInsertTrigger, - afterUpdateTrigger, - afterSoftDeleteTrigger, + afterInsertTrigger(for: syncEngine), + afterUpdateTrigger(for: syncEngine), + afterSoftDeleteTrigger(for: syncEngine), ] } private enum ParentSyncMetadata: AliasName {} - fileprivate static let afterInsertTrigger = createTemporaryTrigger( - "after_insert_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .insert { new in - Values(.didUpdate(new)) - } when: { _ in - !SyncEngine.isSynchronizingChanges() - } - ) - - fileprivate static let afterUpdateTrigger = createTemporaryTrigger( - "after_update_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .update { _, new in - Values(.didUpdate(new)) - } when: { old, new in - old._isDeleted.eq(new._isDeleted) && !SyncEngine.isSynchronizingChanges() - } - ) - - fileprivate static let afterSoftDeleteTrigger = createTemporaryTrigger( - "after_delete_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .update(of: \._isDeleted) { _, new in - Values( - .didDelete( - recordName: new.recordName, - lastKnownServerRecord: new.lastKnownServerRecord - ?? rootServerRecord(recordName: new.recordName), - share: new.share + fileprivate static func afterInsertTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger { + createTemporaryTrigger( + "after_insert_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .insert { new in + validate(recordName: new.recordName) + Values( + syncEngine.$didUpdate( + recordName: new.recordName, + record: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName) + ) ) - ) - } when: { old, new in - !old._isDeleted && new._isDeleted && !SyncEngine.isSynchronizingChanges() - } - ) - } - - extension QueryExpression where Self == SQLQueryExpression<()> { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func didUpdate( - _ new: StructuredQueriesCore.TableAlias< - SyncMetadata, TemporaryTrigger.Operation._New - > - .TableColumns - ) -> Self { - .didUpdate( - recordName: new.recordName, - lastKnownServerRecord: new.lastKnownServerRecord - ?? rootServerRecord(recordName: new.recordName), - share: new.share + } when: { _ in + !SyncEngine.isSynchronizingChanges() + } ) } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - private static func didUpdate( - recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression, - share: some QueryExpression - ) -> Self { - Self( - "\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(recordName), \(lastKnownServerRecord), \(share))" + fileprivate static func afterUpdateTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger { + createTemporaryTrigger( + "after_update_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update { _, new in + validate(recordName: new.recordName) + Values( + syncEngine.$didUpdate( + recordName: new.recordName, + record: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName) + ) + ) + } when: { old, new in + old._isDeleted.eq(new._isDeleted) && !SyncEngine.isSynchronizingChanges() + } ) } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func didDelete( - recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression, - share: some QueryExpression - ) -> Self { - Self( - "\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord), \(share))" + fileprivate static func afterSoftDeleteTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger { + createTemporaryTrigger( + "after_delete_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update(of: \._isDeleted) { _, new in + Values( + syncEngine.$didDelete( + recordName: new.recordName, + record: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName), + share: new.share + ) + ) + } when: { old, new in + !old._isDeleted && new._isDeleted && !SyncEngine.isSynchronizingChanges() + } ) } } - private func isUpdatingWithServerRecord() -> SQLQueryExpression { - #sql("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - private func parentFields( alias: StructuredQueriesCore.TableAlias.TableColumns, parentForeignKey: ForeignKey? @@ -225,6 +203,19 @@ ?? ("NULL", "NULL") } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func validate( + recordName: some QueryExpression + ) -> some StructuredQueriesCore.Statement { + #sql( + """ + SELECT RAISE(ABORT, \(quote: SyncEngine.invalidRecordNameError, delimiter: .text)) + WHERE NOT \(recordName.isValidCloudKitRecordName) + """, + as: Never.self + ) + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func checkWritePermissions( alias: StructuredQueriesCore.TableAlias.TableColumns, @@ -297,4 +288,10 @@ ) } } + + extension QueryExpression { + fileprivate var isValidCloudKitRecordName: some QueryExpression { + substr(1, 1).neq("_") && octetLength().lte(255) && octetLength().eq(length()) + } + } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 471b04f7..2a361d35 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -9,6 +9,7 @@ import StructuredQueriesCore import SwiftData + /// An object that manages the synchronization of local and remote SQLite data. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final class SyncEngine: Sendable { package let userDatabase: UserDatabase @@ -26,8 +27,43 @@ -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) package let container: any CloudContainer let dataManager = Dependency(\.dataManager) + + /// The error message used when a write occurs to a record for which the current user + /// does not have permission. + /// + /// This error is thrown from any database write to a row for which the current user does + /// not have permissions to write, as determined by its `CKShare` (if applicable). To catch + /// this error try casting it to `DatabaseError` and checking its message: + /// + /// ```swift + /// do { + /// try await database.write { db in + /// Reminder.find(id) + /// .update { $0.title = "Personal" } + /// .execute(db) + /// } + /// } catch let error as DatabaseError where error.message == SyncEngine.writePermissionError { + /// // User does not have permission to write to this record. + /// } + /// ``` public static let writePermissionError = "co.pointfree.sqlitedata-icloud.write-permission-error" + public static let invalidRecordNameError = "co.pointfree.sqlitedata-icloud.invalid-record-name-error" + /// Initialize a sync engine. + /// + /// - Parameters: + /// - database: The database to synchronize to CloudKit. + /// - tables: A list of tables that you want to synchronize _and_ that you want to be + /// shareable with other users on CloudKit. + /// - privateTables: A list of tables that you want to synchronize to CloudKit but that + /// you do not want to be shareable with other users. + /// - containerIdentifier: The container identifier in CloudKit to synchronize to. If omitted + /// the container will be determined from the entitlements of your app. + /// - defaultZone: The zone for all records to be stored in. + /// - startImmediately: Determines if the sync engine starts right away or requires an + /// explicit call to ``stop()``. By default this argument is `true`. + /// - logger: The logger used to log events in the sync engine. By default a `.disabled` + /// logger is used, which means logs are not printed. public convenience init( for database: any DatabaseWriter, tables: repeat (each T1).Type, @@ -235,11 +271,11 @@ } db.add(function: $datetime) db.add(function: $syncEngineIsSynchronizingChanges) - db.add(function: .didUpdate(syncEngine: self)) - db.add(function: .didDelete(syncEngine: self)) + db.add(function: $didUpdate) + db.add(function: $didDelete) db.add(function: $hasPermission) - for trigger in SyncMetadata.callbackTriggers { + for trigger in SyncMetadata.callbackTriggers(for: self) { try trigger.execute(db) } @@ -252,11 +288,22 @@ } } } - + + /// Starts the sync engine if it is stopped. + /// + /// When a sync engine is started it will upload all data stored locally that has not yet + /// been synchronized to CloudKit, and will download all changes from CloudKit since the + /// last time it synchronized. + /// + /// > Note: By default, sync engines start syncing when initialized. public func start() async throws { try await start().value } + /// Stops the sync engine if it is running. + /// + /// All edits made after stopping the sync engine will not be synchronized to CloudKit. + /// You must start the sync engine again using ``start()`` to synchronize the changes. public func stop() { guard isRunning else { return } syncEngines.withValue { @@ -264,6 +311,8 @@ } } + // TODO: Should we make isRunning observable? + /// Determines if the sync engine is currently running or not. public var isRunning: Bool { syncEngines.withValue { $0.isRunning @@ -431,12 +480,12 @@ for table in tables { try table.dropTriggers(db: db) } - for trigger in SyncMetadata.callbackTriggers.reversed() { + for trigger in SyncMetadata.callbackTriggers(for: self).reversed() { try trigger.drop().execute(db) } db.remove(function: $hasPermission) - db.remove(function: .didDelete(syncEngine: self)) - db.remove(function: .didUpdate(syncEngine: self)) + db.remove(function: $didDelete) + db.remove(function: $didUpdate) db.remove(function: $syncEngineIsSynchronizingChanges) db.remove(function: $datetime) // TODO: Do an `.erase()` + re-migrate @@ -464,8 +513,12 @@ try setUpSyncEngine() } - func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { - let zoneID = zoneID ?? defaultZone.zoneID + @DatabaseFunction( + "sqlitedata_icloud_didUpdate", + as: ((String, CKRecord?.SystemFieldsRepresentation) -> Void).self + ) + func didUpdate(recordName: String, record: CKRecord?) { + let zoneID = record?.recordID.zoneID ?? defaultZone.zoneID let change = CKSyncEngine.PendingRecordZoneChange.saveRecord( CKRecord.ID( recordName: recordName, @@ -491,8 +544,14 @@ syncEngine?.state.add(pendingRecordZoneChanges: [change]) } - func didDelete(recordName: String, zoneID: CKRecordZone.ID?, share: CKShare?) { - let zoneID = zoneID ?? defaultZone.zoneID + @DatabaseFunction( + "sqlitedata_icloud_didDelete", + as: ( + (String, CKRecord?.SystemFieldsRepresentation, CKShare?.SystemFieldsRepresentation) -> Void + ).self + ) + func didDelete(recordName: String, record: CKRecord?, share: CKShare?) { + let zoneID = record?.recordID.zoneID ?? defaultZone.zoneID var changes: [CKSyncEngine.PendingRecordZoneChange] = [ .deleteRecord( CKRecord.ID( @@ -539,6 +598,10 @@ ) } + /// A query expression that can be used in SQL queries to determine if the ``SyncEngine`` + /// is currently writing changes to the database. + /// + /// See for more info. public static func isSynchronizingChanges() -> some QueryExpression { $syncEngineIsSynchronizingChanges() } @@ -1574,56 +1637,6 @@ } } - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - extension GRDB.DatabaseFunction { - fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { recordName, zoneID, _ in - syncEngine.didUpdate( - recordName: recordName, - zoneID: zoneID - ) - } - } - - fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { - return Self("didDelete") { recordName, zoneID, share in - syncEngine - .didDelete( - recordName: recordName, - zoneID: zoneID, - share: share - ) - } - } - - private convenience init( - _ name: String, - function: @escaping @Sendable (String, CKRecordZone.ID?, CKShare?) -> Void - ) { - self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 3) { arguments in - guard - let recordName = String.fromDatabaseValue(arguments[0]) - else { - return nil - } - let zoneID = try Data.fromDatabaseValue(arguments[1]).flatMap { - let coder = try NSKeyedUnarchiver(forReadingFrom: $0) - coder.requiresSecureCoding = true - return CKRecord(coder: coder)?.recordID.zoneID - } - - let share = try Data.fromDatabaseValue(arguments[2]).flatMap { - let coder = try NSKeyedUnarchiver(forReadingFrom: $0) - coder.requiresSecureCoding = true - return CKShare(coder: coder) - } - - function(recordName, zoneID, share) - return nil - } - } - } - extension String { package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" package static let sqliteDataCloudKitFailure = "SQLiteData CloudKit Failure" diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 2ecaf3e0..2c676081 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -8,7 +8,7 @@ /// application is the number of rows this one single table holds. However, this table is held /// in a database separate from your app's database. /// - /// +/// See for more info. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Table("sqlitedata_icloud_metadata") public struct SyncMetadata: Hashable, Sendable { @@ -131,7 +131,10 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { +extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { + /// A query for finding the metadata associated with a record. + /// + /// - Parameter primaryKey: The primary key of the record whose metadata to look up. public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where { SyncMetadata.where { #sql( diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 38983b4e..f0c1e3e1 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -71,8 +71,7 @@ Before constructing a ``SyncEngine`` you must have already created and migrated SQLite database as detailed in . Immediately after that is done in the `prepareDependencies` of the entry point of your app you will override the ``Dependencies/DependencyValues/defaultSyncEngine`` dependency with a sync engine that specifies -the CloudKit container to use, the database to synchronize, as well as the tables you want to -synchronize: +the database to synchronize, as well as the tables you want to synchronize: ```swift @main @@ -96,7 +95,8 @@ The `SyncEngine` has more options you may be interested in configuring. > Important: You must explicitly provide all tables that you want to synchronize. We do this so that -> you can have the option of having some local tables that are not synchronized to CloudKit. +> you can have the option of having some local tables that are not synchronized to CloudKit, such as +> full-text search indices, cached data, etc. Once this work is done the app should work exactly as it did before, but now any changes made to the database will be synchronized to CloudKit. You will still interact with your local SQLite @@ -143,7 +143,7 @@ a unique ID by simply adding 1 to the largest ID in the table. However, that doe with distributed schemas. That would make it possible for two devices to create a record with `id: 1`, and when those records synchronize there would be an irreconcilable conflict. -For this reason, primary keys in SQLite tables should be globally unique, such as a UUID. The +For this reason, primary keys in SQLite tables should be _globally_ unique, such as a UUID. The easiest way to do this is to store your table's ID in a `TEXT` column, adding a default with a freshly generated UUID, and further adding a `ON CONFLICT REPLACE` constraint: @@ -163,7 +163,7 @@ the primary key from the default value specified. This kind of pattern is common ```swift try database.write { db in try Reminder.upsert { - // Do not provide 'id', let database initialize it for you. + // ℹ️ Omitting 'id' allows the database to initialize it for you. Reminder.Draft(title: "Get milk") } .execute(db) @@ -183,8 +183,8 @@ CREATE TABLE "reminders" ( ) ``` -> Tip: If you want the database to generate random UUID's in a deterministic fashion for tests -> you can register a custom database function to be used. +Registering custom database functions for ID generation also makes it possible to generate +deterministic IDs for tests, making it easier to test your queries. #### Primary keys on every table @@ -201,7 +201,7 @@ CREATE TABLE "reminderTags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE -) +) STRICT ``` Note that the `id` column might not be needed for your application's logic, but it is necessary to @@ -223,6 +223,12 @@ For this reason uniqueness constraints are not allowed in schemas, and this will when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be thrown. +Sometimes it is possible to make the column that you want to be unique + +> Important: discuss limitations of custom PK + +[CKRecord.ID]: https://developer.apple.com/documentation/cloudkit/ckrecord/id + #### Foreign key relationships > TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md index 50bc341d..f599c68d 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md @@ -70,6 +70,11 @@ the view. That will cause a ``CloudSharingView`` sheet to be presented where the how they want to share the record. A record can be _unshared_ by presenting the same ``CloudSharingView`` to the user so that they can tap the "Stop sharing" button in the UI. +If you would like to provide a custom sharing experience outside of what `UICloudSharingController` +offers, you can find more info in [Apple's documentation]. + +[Apple's documentation]: https://developer.apple.com/documentation/cloudkit/shared-records + ## Accepting shared records Extra steps must be taken to allow a user to _accept_ a shared record. Once the user taps on the diff --git a/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md index 16e5b97d..8a9bb691 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -39,7 +39,7 @@ to SwiftData's `@Model` macro: // SQLiteData @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true var notes = "" @@ -610,7 +610,7 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a // SQLiteData @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true } @@ -652,7 +652,7 @@ adding a `description` field to the `Item` type: ```swift @Table struct Item { - let id: Int + let id: UUID var title = "" var description = "" var isInStock = true diff --git a/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md index a875f2b5..9d4a7dca 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md @@ -89,3 +89,6 @@ struct ContentView: View { > initial `@FetchAll`'s value, taken from the parent. To manage the state of this dynamic query > locally to this view, we use `@State @FetchAll`, instead, and to access the underlying > `FetchAll` value you can use `wrappedValue`. +> +> This only happens when using `@FetchAll`/`@FetchOne`/`@Fetch` directly in a view, and does not +> affect using these tools elsewhere in your application. diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md index acb51ded..f0c8b278 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md @@ -26,7 +26,7 @@ your table: ```swift @Table struct Reminder { - let id: Int + let id: UUID var title = "" var dueAt: Date? var isCompleted = false @@ -97,7 +97,7 @@ exactly one list: ```swift @Table struct Reminder { - let id: Int + let id: UUID var title = "" var dueAt: Date? var isCompleted = false @@ -105,7 +105,7 @@ struct Reminder { } @Table struct RemindersList: Identifiable { - let id: Int + let id: UUID var title = "" } ``` @@ -151,9 +151,9 @@ you must construct a dedicated `FetchDescriptor` value and set its `propertiesTo ### @FetchOne The [`@FetchOne`]() property wrapper works similarly to `@FetchAll`, but fetches -only a single record from the database and you must provide a default for when no record is found. -This tool can be handy for computing aggregate data, such as the number of reminders in the -database: +only a single record from the database and you must provide a default for when no record is found or +use an optional value. This tool can be handy for computing aggregate data, such as the number of +reminders in the database: ```swift @FetchOne(Reminder.count()) @@ -179,9 +179,9 @@ var completedRemindersCount = 0 It is also possible to execute multiple database queries to fetch data for your features. This can be useful for performing several queries in a single database transaction: -Each instance of `@FetchAll` in a feature executes their queries in a separate transaction. So, if -we wanted to query for all completed reminders, along with a total count of reminders (completed and -uncompleted), we could do so like this: +Each instance of `@FetchAll` and `@FetchOne` executes their queries in a separate transaction and +manage separate observations of the database. So, if we wanted to query for all completed reminders, +along with a total count of reminders (completed and uncompleted), we could do so like this: ```swift @FetchOne(Reminder.count()) @@ -191,13 +191,13 @@ var remindersCount = 0 var completedReminders ``` -…this is technically 2 queries run in 2 separate database transactions. +…this is technically 2 separate database transactions with 2 separate observations. Often this can be just fine, but if you have multiple queries that tend to change at the same time (_e.g._, when reminders are created or deleted, `remindersCount` and `completedReminders` will change at the same time), then you can bundle these two queries into a single transaction. -To do this, one simply defines a conformance to our ``FetchKeyRequest`` protocol, and in that +To do this, one defines a conformance to our ``FetchKeyRequest`` protocol, and in that conformance one can use the builder tools to query the database: ```swift diff --git a/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md index 1239280b..07c87d7e 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md @@ -14,6 +14,7 @@ and Xcode previews. * [Step 3: Create database connection](#Step-3-Create-database-connection) * [Step 4: Migrate database](#Step-4-Migrate-database) * [Step 5: Set database connection in entry point](#Step-5-Set-database-connection-in-entry-point) +* [(Optional) Step 6: Set up CloudKit SyncEngine](#Optional-Step-6-Set-up-CloudKit-SyncEngine) ### Step 1: App database connection @@ -45,10 +46,7 @@ data: + configuration.foreignKeysEnabled = true } ``` - -> Important: If you are synchronizing your database to CloudKit, then you must not enable -> foreign keys. See for more information. - + This will prevent you from deleting rows that leave other rows with invalid associations. For example, if a "reminders" table had an association to a "remindersLists" table, you would not be allowed to delete a list row unless there were no reminders associated with it, or if you had @@ -156,16 +154,16 @@ context or if we're in a preview or test. - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() - logger.info("open \(path)") - let database = try DatabasePool(path: path, configuration: configuration) -+ @Dependency(\.context) var context + let database: any DatabaseWriter -+ if context == .live { ++ switch context { ++ case .live: + let path = URL.documentsDirectory.appending(component: "db.sqlite").path() + logger.info("open \(path)") + database = try DatabasePool(path: path, configuration: configuration) -+ } else if context == .test { ++ case .test: + let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + database = try DatabasePool(path: path, configuration: configuration) -+ } else { ++ case .preview: + database = try DatabaseQueue(configuration: configuration) + } return database @@ -196,14 +194,15 @@ database connection: } #endif let database: any DatabaseWriter - if context == .live { + switch context { + case .live: let path = URL.documentsDirectory.appending(component: "db.sqlite").path() logger.info("open \(path)") database = try DatabasePool(path: path, configuration: configuration) - } else if context == .test { + case .test: let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() database = try DatabasePool(path: path, configuration: configuration) - } else { + case .preview: database = try DatabaseQueue(configuration: configuration) } + var migrator = DatabaseMigrator() @@ -265,31 +264,32 @@ import OSLog import SQLiteData func appDatabase() throws -> any DatabaseWriter { - @Dependency(\.context) var context - var configuration = Configuration() - configuration.foreignKeysEnabled = true - #if DEBUG - configuration.prepareDatabase { db in - db.trace(options: .profile) { - if context == .preview { - print("\($0.expandedDescription)") - } else { - logger.debug("\($0.expandedDescription)") - } - } - } - #endif - let database: any DatabaseWriter - if context == .live { - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() - logger.info("open \(path)") - database = try DatabasePool(path: path, configuration: configuration) - } else if context == .test { - let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - database = try DatabasePool(path: path, configuration: configuration) - } else { - database = try DatabaseQueue(configuration: configuration) - } + @Dependency(\.context) var context + var configuration = Configuration() + configuration.foreignKeysEnabled = true + #if DEBUG + configuration.prepareDatabase { db in + db.trace(options: .profile) { + if context == .preview { + print("\($0.expandedDescription)") + } else { + logger.debug("\($0.expandedDescription)") + } + } + } + #endif + let database: any DatabaseWriter + switch context { + case .live: + let path = URL.documentsDirectory.appending(component: "db.sqlite").path() + logger.info("open \(path)") + database = try DatabasePool(path: path, configuration: configuration) + case .test: + let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + database = try DatabasePool(path: path, configuration: configuration) + case .preview: + database = try DatabaseQueue(configuration: configuration) + } var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true @@ -369,3 +369,9 @@ func feature() { // ... } ``` + +### (Optional) Step 6: Set up CloudKit SyncEngine + +If you plan on synchronizing your local database to CloudKit so that your user's data is available +on all of their devices, there is an additional step you must take. See + for more information. diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 4bf75257..54e7464f 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -17,7 +17,7 @@ of targets. @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true var notes = "" @@ -117,7 +117,12 @@ This `defaultDatabase` connection is used implicitly by SQLiteData's property wr @FetchAll(Item.where(\.isInStock)) var items - + + + + @FetchAll(Item.order(by: \.isInStock)) + var items + @FetchOne(Item.count()) var itemsCount = 0 ``` @@ -130,8 +135,13 @@ This `defaultDatabase` connection is used implicitly by SQLiteData's property wr @Query(sort: [SortDescriptor(\.title)]) var items: [Item] - // No @Query equivalent of filtering - // by 'isInStock: Bool' + @Query(filter: #Predicate { + $0.isInStock + }) + var items: [Item] + + // No @Query equivalent of ordering + // by boolean column. // No @Query equivalent of counting // entries in database without loading @@ -150,8 +160,8 @@ a model context, via a property wrapper: @Dependency(\.defaultDatabase) var database try database.write { db in - try Item.insert(Item(/* ... */)) - .execute(db) + try Item.insert { Item(/* ... */) } + .execute(db) } ``` } @@ -189,12 +199,9 @@ struct MyApp: App { } ``` -> [!NOTE] > For more information on synchronizing the database to CloudKit and sharing records with iCloud > users, see . -[CloudKit Synchronization] - This is all you need to know to get started with SQLiteData, but there's much more to learn. Read the [articles](#Essentials) below to learn how to best utilize this library. @@ -210,14 +217,16 @@ See the following benchmarks against taste of how it compares: ``` -Orders.fetchAll setup rampup duration - SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 - Lighter (1.4.10) 0 0.164 8.059 - SQLiteData (1.0.0) 0 0.172 8.511 - GRDB (7.4.1, manual decoding) 0 0.376 18.819 - SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 - SQLite.swift (0.15.3, Codable) 0 0.863 43.261 - GRDB (7.4.1, Codable) 0.002 1.07 53.326 +Orders.fetchAll setup rampup duration + SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 + Lighter (1.4.10) 0 0.164 8.059 +┌──────────────────────────────────────────────────────────────────┐ +│ SQLiteData (1.0.0) 0 0.172 8.511 │ +└──────────────────────────────────────────────────────────────────┘ + GRDB (7.4.1, manual decoding) 0 0.376 18.819 + SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 + SQLite.swift (0.15.3, Codable) 0 0.863 43.261 + GRDB (7.4.1, Codable) 0.002 1.07 53.326 ``` ## SQLite knowledge required diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index ad47b160..73c2f64a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -122,6 +122,7 @@ } } + // TODO: look into if there are more tests to write here @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func doNotUploadExistingDataToCloudKitWhenSignedOut() { } diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index a39bc452..c0847578 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -542,6 +542,239 @@ """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createTagLocallyThenCreateSameTagRemotely() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Tag(title: "tag") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let tagRecord = CKRecord( + recordType: Tag.tableName, + recordID: Tag.recordID(for: "tag") + ) + tagRecord.encryptedValues["title"] = "tag" + try await syncEngine.modifyRecords(scope: .private, saving: [tagRecord]).notify() + + assertQuery(Tag.all, database: userDatabase.database) { + """ + ┌───────────────────┐ + │ Tag(title: "tag") │ + └───────────────────┘ + """ + } + assertQuery(SyncMetadata.all, database: userDatabase.database) { + """ + ┌────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "tag", │ + │ recordType: "tags", │ + │ recordName: "tag:tags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil, │ + │ title: "tag" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: 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(tag:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "tag" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createTagRemotelyThenCreateSameTagLocally() async throws { + let tagRecord = CKRecord( + recordType: Tag.tableName, + recordID: Tag.recordID(for: "tag") + ) + tagRecord.encryptedValues["title"] = "tag" + let modifications = try syncEngine.modifyRecords(scope: .private, saving: [tagRecord]) + + try await userDatabase.userWrite { db in + try db.seed { + Tag(title: "tag") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + + assertQuery(Tag.all, database: userDatabase.database) { + """ + ┌───────────────────┐ + │ Tag(title: "tag") │ + └───────────────────┘ + """ + } + assertQuery(SyncMetadata.all, database: userDatabase.database) { + """ + ┌────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "tag", │ + │ recordType: "tags", │ + │ recordName: "tag:tags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil, │ + │ title: "tag" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: 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(tag:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "tag" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.userWrite { db in + try Tag.find("tag").update { $0.title = "weekend" }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(Tag.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Tag(title: "weekend") │ + └───────────────────────┘ + """ + } + assertQuery(SyncMetadata.all, database: userDatabase.database) { + """ + ┌────────────────────────────────────────────────────────────────┐ + │ 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) │ + │ ) │ + └────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "weekend" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func invalidRecordName() async throws { + let error = await #expect(throws: DatabaseError.self) { + try await self.userDatabase.userWrite { db in + try Tag.insert { Tag(title: "_tag") }.execute(db) + } + } + #expect(error?.message == SyncEngine.invalidRecordNameError) + } } } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index d0e71c1f..fd94e0f8 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -385,22 +385,6 @@ #expect(error == CKError(.notAuthenticated)) } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func incorrectlyCreatingNewRecordIdentity() async throws { - let record1 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) - _ = try syncEngine.modifyRecords(scope: .private, saving: [record1]) - let record2 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) - try withKnownIssue { - _ = try syncEngine.modifyRecords(scope: .private, saving: [record2]) - } matching: { issue in - issue.description == """ - Issue recorded: A new identity was created for an existing 'CKRecord' ('1'). Rather than \ - creating 'CKRecord' from scratch for an existing record, use the database to fetch the \ - current record. - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func saveShareWithoutRootRecord() async throws { let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index 9dcbf44b..31efb4e2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -93,6 +93,11 @@ } } + // * Create list + // * Stop sync engine + // * Delete list + // * Start sync engine + // => List is deleted from CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func writeStopDeleteStart() async throws { try await userDatabase.userWrite { db in @@ -129,6 +134,10 @@ } } + // * Stop sync engine + // * Edit list + // * Start sync engine + // => List is updated on CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { try await userDatabase.userWrite { db in diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index c0e8ad90..5f21b04a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -22,7 +22,7 @@ CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" AFTER UPDATE OF "_isDeleted" ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN ((NOT ("old"."_isDeleted") AND "new"."_isDeleted") AND NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN - SELECT sqlitedata_icloud_didDelete("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + SELECT "sqlitedata_icloud_didDelete"("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" @@ -42,7 +42,9 @@ CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.invalid-record-name-error') + WHERE NOT (((substr("new"."recordName", 1, 1) <> '_') AND (octet_length("new"."recordName") <= 255)) AND (octet_length("new"."recordName") = length("new"."recordName"))); + SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" @@ -55,14 +57,16 @@ SELECT "ancestorMetadatas"."lastKnownServerRecord" FROM "ancestorMetadatas" WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - )), "new"."share"); + ))); END """, [2]: """ CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN (("old"."_isDeleted" = "new"."_isDeleted") AND NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.invalid-record-name-error') + WHERE NOT (((substr("new"."recordName", 1, 1) <> '_') AND (octet_length("new"."recordName") <= 255)) AND (octet_length("new"."recordName") = length("new"."recordName"))); + SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" @@ -75,7 +79,7 @@ SELECT "ancestorMetadatas"."lastKnownServerRecord" FROM "ancestorMetadatas" WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - )), "new"."share"); + ))); END """, [3]: """ From 543684b23b01c26b421d7d518e645edcfdcce1e6 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 3 Sep 2025 13:55:58 -0700 Subject: [PATCH 530/581] Fetch pending record zone changes through metadatabase (#155) --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 2 +- .../Internal/UserDatabase.swift | 4 +-- .../UnattachedSyncEngineTests.swift | 25 +++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 Tests/SharingGRDBTests/CloudKitTests/UnattachedSyncEngineTests.swift diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index f78e26dc..047a5cce 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -339,7 +339,7 @@ previousRecordTypeByTableName: [String: RecordType], currentRecordTypeByTableName: [String: RecordType] ) async throws { - let pendingRecordZoneChanges = try await userDatabase.read { db in + let pendingRecordZoneChanges = try await metadatabase.read { db in try PendingRecordZoneChange .select(\.pendingRecordZoneChange) .fetchAll(db) diff --git a/Sources/SharingGRDBCore/Internal/UserDatabase.swift b/Sources/SharingGRDBCore/Internal/UserDatabase.swift index 95299501..3fd2e24f 100644 --- a/Sources/SharingGRDBCore/Internal/UserDatabase.swift +++ b/Sources/SharingGRDBCore/Internal/UserDatabase.swift @@ -31,9 +31,7 @@ package struct UserDatabase { _ updates: @Sendable (Database) throws -> T ) async throws -> T { try await database.read { db in - try SyncEngine.$_isSynchronizingChanges.withValue(true) { - try updates(db) - } + try updates(db) } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/UnattachedSyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/UnattachedSyncEngineTests.swift new file mode 100644 index 00000000..ae726da1 --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/UnattachedSyncEngineTests.swift @@ -0,0 +1,25 @@ +import CloudKit +import CustomDump +import InlineSnapshotTesting +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class UnattachedSyncEngineTests: @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func start() async throws { + let database = try DatabasePool(path: "\(NSTemporaryDirectory())\(UUID())") + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "iCloud.co.pointfree.Testing", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [] + ) + } + } +} From 7fd7902c329d7b970abe6ce293cc016133a68423 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 3 Sep 2025 13:57:50 -0700 Subject: [PATCH 531/581] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 22 ++-- .../CloudKitTests/TriggerTests.swift | 100 +++++++++--------- .../UnattachedSyncEngineTests.swift | 2 +- 3 files changed, 63 insertions(+), 61 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f167e309..0b0866ed 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -46,8 +46,10 @@ /// // User does not have permission to write to this record. /// } /// ``` - public static let writePermissionError = "co.pointfree.sqlitedata-icloud.write-permission-error" - public static let invalidRecordNameError = "co.pointfree.sqlitedata-icloud.invalid-record-name-error" + public static let writePermissionError = + "co.pointfree.SQLiteData.CloudKit.write-permission-error" + public static let invalidRecordNameError = + "co.pointfree.SQLiteData.CloudKit.invalid-record-name-error" /// Initialize a sync engine. /// @@ -185,10 +187,11 @@ package init( container: any CloudContainer, defaultZone: CKRecordZone, - defaultSyncEngines: @escaping @Sendable ( - any DatabaseReader, - SyncEngine - ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), + defaultSyncEngines: + @escaping @Sendable ( + any DatabaseReader, + SyncEngine + ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), userDatabase: UserDatabase, logger: Logger, tables: [any PrimaryKeyedTable.Type], @@ -288,7 +291,7 @@ } } } - + /// Starts the sync engine if it is stopped. /// /// When a sync engine is started it will upload all data stored locally that has not yet @@ -546,9 +549,8 @@ @DatabaseFunction( "sqlitedata_icloud_didDelete", - as: ( - (String, CKRecord?.SystemFieldsRepresentation, CKShare?.SystemFieldsRepresentation) -> Void - ).self + as: ((String, CKRecord?.SystemFieldsRepresentation, CKShare?.SystemFieldsRepresentation) + -> Void).self ) func didDelete(recordName: String, record: CKRecord?, share: CKShare?) { let zoneID = record?.recordID.zoneID ?? defaultZone.zoneID diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index 5f21b04a..5c407e2a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -42,7 +42,7 @@ CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.invalid-record-name-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.invalid-record-name-error') WHERE NOT (((substr("new"."recordName", 1, 1) <> '_') AND (octet_length("new"."recordName") <= 255)) AND (octet_length("new"."recordName") = length("new"."recordName"))); SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( @@ -64,7 +64,7 @@ CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN (("old"."_isDeleted" = "new"."_isDeleted") AND NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.invalid-record-name-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.invalid-record-name-error') WHERE NOT (((substr("new"."recordName", 1, 1) <> '_') AND (octet_length("new"."recordName") <= 255)) AND (octet_length("new"."recordName") = length("new"."recordName"))); SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( @@ -103,7 +103,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -132,7 +132,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -161,7 +161,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -190,7 +190,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -219,7 +219,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -248,7 +248,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -277,7 +277,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -306,7 +306,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -335,7 +335,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -364,7 +364,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -393,7 +393,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -422,7 +422,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -443,7 +443,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -466,7 +466,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -489,7 +489,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -512,7 +512,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -535,7 +535,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -558,7 +558,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -581,7 +581,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -604,7 +604,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -627,7 +627,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -650,7 +650,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -673,7 +673,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -696,7 +696,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -719,7 +719,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -740,7 +740,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -761,7 +761,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -782,7 +782,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -803,7 +803,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -824,7 +824,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -845,7 +845,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -866,7 +866,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -887,7 +887,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -908,7 +908,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -929,7 +929,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -950,7 +950,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" @@ -971,7 +971,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -994,7 +994,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1017,7 +1017,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1040,7 +1040,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1063,7 +1063,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1086,7 +1086,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1109,7 +1109,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1132,7 +1132,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1155,7 +1155,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1178,7 +1178,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1201,7 +1201,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" @@ -1224,7 +1224,7 @@ FROM "sqlitedata_icloud_metadata" JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName" IS "rootShares"."parentRecordName") ) - SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.write-permission-error') + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') FROM "rootShares" WHERE ((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) AND ("rootShares"."parentRecordName" IS NULL)) AND NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share"))); INSERT INTO "sqlitedata_icloud_metadata" diff --git a/Tests/SharingGRDBTests/CloudKitTests/UnattachedSyncEngineTests.swift b/Tests/SharingGRDBTests/CloudKitTests/UnattachedSyncEngineTests.swift index ae726da1..1133fa7a 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/UnattachedSyncEngineTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/UnattachedSyncEngineTests.swift @@ -1,7 +1,7 @@ import CloudKit import CustomDump import InlineSnapshotTesting -import SharingGRDB +import SQLiteData import SnapshotTestingCustomDump import Testing From 2676910600978e4809ac3ff850a364bfc3c5edba Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 3 Sep 2025 15:58:26 -0500 Subject: [PATCH 532/581] docs on uniqueness --- .../Documentation.docc/Articles/CloudKit.md | 78 ++++++++++++------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index f0c1e3e1..cfdfa26e 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -11,31 +11,32 @@ to make, and so an abundance of care must be taken to make sure all devices rema and capable of communicating with each other. Please read the documentation closely and thoroughly to make sure you understand how to best prepare your app for cloud synchronization. - * [Setting up your project](#Setting-up-your-project) - * [Setting up a SyncEngine](#Setting-up-a-SyncEngine) - * [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) - * [Primary keys](#Primary-keys) - * [Primary keys on every table](#Primary-keys-on-every-table) - * [Foreign key relationships](#Foreign-key-relationships) - * [Record conflicts](#Record-conflicts) - * [Backwards compatible migrations](#Backwards-compatible-migrations) - * [Adding tables](#Adding-tables) - * [Adding columns](#Adding-columns) - * [Disallowed migrations](#Disallowed-migrations) - * [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) - * [Assets](#Assets) - * [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) - * [How SQLiteData handles distributed schema scenarios](#How-SQLiteData-handles-distributed-schema-scenarios) - * [Unit testing and Xcode previews](#Unit-testing-and-Xcode-previews) - * [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) - * [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) - * [Add primary key to all tables](#Add-primary-key-to-all-tables) - * [Migrating from Swift Data to SQLiteData](#Migrating-from-Swift-Data-to-SQLiteData) - * [Separating schema migrations from data migrations](#Separating-schema-migrations-from-data-migrations) - * [Tips and tricks](#Tips-and-tricks) - * [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization) - * [Topics](#Topics) - * [Go deeper](#Go-deeper) + - [Setting up your project](#Setting-up-your-project) + - [Setting up a SyncEngine](#Setting-up-a-SyncEngine) + - [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) + - [Primary keys](#Primary-keys) + - [Primary keys on every table](#Primary-keys-on-every-table) + - [Uniqueness constraints](#Uniqueness-constraints) + - [Foreign key relationships](#Foreign-key-relationships) + - [Record conflicts](#Record-conflicts) + - [Backwards compatible migrations](#Backwards-compatible-migrations) + - [Adding tables](#Adding-tables) + - [Adding columns](#Adding-columns) + - [Disallowed migrations](#Disallowed-migrations) + - [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) + - [Assets](#Assets) + - [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) + - [How SQLiteData handles distributed schema scenarios](#How-SQLiteData-handles-distributed-schema-scenarios) + - [Unit testing and Xcode previews](#Unit-testing-and-Xcode-previews) + - [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) + - [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) + - [Add primary key to all tables](#Add-primary-key-to-all-tables) + - [Migrating from Swift Data to SQLiteData](#Migrating-from-Swift-Data-to-SQLiteData) + - [Separating schema migrations from data migrations](#Separating-schema-migrations-from-data-migrations) + - [Tips and tricks](#Tips-and-tricks) + - [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization) + - [Topics](#Topics) + - [Go deeper](#Go-deeper) ## Setting up your project @@ -207,7 +208,7 @@ CREATE TABLE "reminderTags" ( Note that the `id` column might not be needed for your application's logic, but it is necessary to facilitate synchronizing to CloudKit. -#### Unique constraints +#### Uniqueness constraints > TL;DR: SQLite tables cannot have `UNIQUE` constraints on their columns in order to allow > for distributed creation of records. @@ -223,9 +224,30 @@ For this reason uniqueness constraints are not allowed in schemas, and this will when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be thrown. -Sometimes it is possible to make the column that you want to be unique +Sometimes it is possible to make the column that you want to be unique into the primary key of +your table. For example, tags with a unique title could be modeled like so: -> Important: discuss limitations of custom PK +```swift +@Table struct Tag { + let title: String +} +// CREATE TABLE "tags" ( +// "title" TEXT NOT NULL PRIMARY KEY +// ) STRICT +``` + +This will make it so that there can be at most one tag with a specific title. However, there are +important caveats to be aware of: + +> Important: The primary key of a row is encoded into the `recordName` of a `CKRecord`, along with +> the table name. There are [restrictions][CKRecord.ID] on the value of `recordName`: +> +> * It may only contain ASCII characters +> * It must be less than 255 characters +> * It must not begin with an underscore +> +> If your primary key violates any of these rules, a `DatabaseError` will be thrown with a message +> of ``SyncEngine/invalidRecordNameError``. [CKRecord.ID]: https://developer.apple.com/documentation/cloudkit/ckrecord/id From d614c996889d3725c0ed6caff2a32f5374f8d711 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 3 Sep 2025 20:20:50 -0700 Subject: [PATCH 533/581] Update README.md --- Examples/README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/Examples/README.md b/Examples/README.md index 9a17af51..26b2754f 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -1,21 +1,19 @@ # Examples -This directory holds many case studies and applications to demonstrate solving various problems -with [SQLiteData](http://github.com/pointfreeco/sqlite-data). Open the -`SQLiteData.xcworkspace` at the root of the repo to see all example projects in one single -workspace, or you can open each example application individually. +This directory holds many case studies and applications to demonstrate solving various problems +with [SQLiteData](http://github.com/pointfreeco/sqlite-data). * **Case Studies** -
Demonstrates how to solve some common application problems in an isolated environment, in +
Demonstrates how to solve some common application problems in an isolated environment, in both SwiftUI and UIKit. Things like animations, dynamic queries, database transactions, and more. * **Reminders** -
A rebuild of Apple's [Reminders][reminders-app-store] app that uses a SQLite database to - model the reminders, lists and tags. It features many advanced queries, such as searching, - stats aggregation, and multi-table joins. +
A rebuild of Apple's [Reminders][reminders-app-store] app that uses a SQLite database to + model the reminders, lists and tags. It features many advanced queries, such as searching, stats + aggregation, and multi-table joins. It also features CloudKit synchronization and sharing. * **SyncUps** -
This application is a faithful reconstruction of one of Apple's more interesting sample +
This application is a faithful reconstruction of one of Apple's more interesting sample projects, called [Scrumdinger][scrumdinger], and uses SQLite to persist the data for meetings. [scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger From 1c4d307e82d48888fdea99cc0fd50a4a4e904a8a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 3 Sep 2025 23:09:17 -0700 Subject: [PATCH 534/581] wip --- .../Articles/Deprecations.md | 14 ---- .../Articles/MigrationGuides.md | 17 ----- .../MigrationGuides/MigratingTo0.2.md | 70 ------------------- .../Documentation.docc/Extensions/FetchKey.md | 8 --- .../Extensions/FetchKeyRequest.md | 4 -- .../Documentation.docc/SQLiteData.md | 5 -- 6 files changed, 118 deletions(-) delete mode 100644 Sources/SQLiteData/Documentation.docc/Articles/Deprecations.md delete mode 100644 Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md delete mode 100644 Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md delete mode 100644 Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Deprecations.md b/Sources/SQLiteData/Documentation.docc/Articles/Deprecations.md deleted file mode 100644 index b2eb37c8..00000000 --- a/Sources/SQLiteData/Documentation.docc/Articles/Deprecations.md +++ /dev/null @@ -1,14 +0,0 @@ -# Deprecations - -Review unsupported APIs and their replacements. - -## Overview - -Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use -instead. - -## Topics - -### Sharing extensions - -- ``Sharing`` diff --git a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md deleted file mode 100644 index 55edfbcd..00000000 --- a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md +++ /dev/null @@ -1,17 +0,0 @@ -# Migration guides - -Learn how to upgrade your application to the newest version of SQLiteData. - -## Overview - -SQLiteData is under constant development, and we are always looking for ways to -simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs -in favor of newer ones. We recommend people update their code as quickly as possible to the newest -APIs, and these guides contain tips to do so. - -> Important: Before following any particular migration guide be sure you have followed all the -> preceding migration guides. - -## Topics - -- diff --git a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md deleted file mode 100644 index d8a445e5..00000000 --- a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md +++ /dev/null @@ -1,70 +0,0 @@ -# Migrating to 0.2 - -Update your code to make use of powerful new querying capabilities. - -## Overview - -SQLiteData is under constant development, and we are always looking for ways to -simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs -in favor of newer ones. We recommend people update their code as quickly as possible to the newest -APIs, and these guides contain tips to do so. - -* [@FetchAll, @FetchOne, @Fetch](#) -* [fetchAll, fetchOne, fetch: soft-deprecated](#) -* [Avoiding the cost of macros](#) - -## @FetchAll, @FetchOne, @Fetch - -SQLiteData 0.2.0 comes with 3 brand new property wrappers that largely replace the need for -SwiftData and its `@Query` macro. In 0.1.0, one would perform queries as either a hard coded SQL -string: - -```swift -@SharedReader(.fetchAll(sql: "SELECT * FROM reminders WHERE isCompleted ORDER BY title")) -var completedReminders: [Reminder] -``` - -Or by defining a ``FetchKeyRequest`` conformance to perform a query using GRDB's query builder: - -```swift -struct CompletedReminders: FetchKeyRequest { - func fetch(_ db: Database) throws -> [Reminder] { - Reminder.all() - .where(Column("isCompleted")) - .order(Column("title")) - } -} - -@SharedReader(.fetch(CompletedReminders())) -var completedReminders -``` - -Each of these are cumbersome, and version 0.2.0 of SQLiteData fixes things thanks to our newly -released [StructuredQueries][] library. You can now describe the query for your data in a type-safe -manner, and directly inline: - -```swift -@FetchAll(Reminder.where(\.isCompleted).order(by: \.title)) -var completedReminders: [Reminder] -``` - -Read for more information on how to use these new property wrappers. - -[StructuredQueries]: http://github.com/pointfreeco/swift-structured-queries - -## fetchAll, fetchOne, fetch: soft-deprecated - -The [`.fetchAll`](), -[`.fetchOne`](), -and [`.fetch`]() APIs have been soft-deprecated -in favor of the more modern tools described above and in . They will be hard -deprecated in a future release of SQLiteData, and removed in 1.0. - -## Avoiding the cost of macros - -SQLiteData introduces a macro in version 0.2.0 (in particular, the `@Table` macro), and -unfortunately macros currently come with an unfortunate cost in that you have to compile SwiftSyntax -from scratch, which can take time. If the cost of macros is too high for you, then you can depend -on the SQLiteDataCore module instead of the full SQLiteData module. This will give you access to -only a subset of tools provided by SQLiteData, but you will have access to all tools that were -available in version 0.1.0 of the library. diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md deleted file mode 100644 index 567eba3a..00000000 --- a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKey.md +++ /dev/null @@ -1,8 +0,0 @@ -# ``SQLiteData/FetchKey`` - -## Topics - -### Key identity - -- ``FetchKeyID`` -- ``ID-swift.typealias`` diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md index d0c6868e..732a804b 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchKeyRequest.md @@ -2,10 +2,6 @@ ## Topics -### Fetch keys - -- ``FetchKey`` - ### Error handling - ``NotFound`` diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 54e7464f..86d76c13 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -279,7 +279,6 @@ with SQLite to take full advantage of GRDB and SQLiteData. - - - -- ### Database configuration and access @@ -302,7 +301,3 @@ with SQLite to take full advantage of GRDB and SQLiteData. ### Seeding data - ``GRDB/Database/seed(_:)`` - -### Deprecated interfaces - -- From fda2cde7ffc00182f78fffb39401fb625528c9e6 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Thu, 4 Sep 2025 12:25:26 -0500 Subject: [PATCH 535/581] Triage some todos (#157) * Triage todos' * wip * wip * wip * wip * wip --- .../xcshareddata/swiftpm/Package.resolved | 6 +- Examples/Reminders/RemindersDetail.swift | 2 +- README.md | 2 +- .../SQLiteData/CloudKit/CloudKitSharing.swift | 6 +- .../CloudKit/Internal/Metadatabase.swift | 16 +- .../CloudKit/Internal/SQLiteSchema.swift | 10 +- .../CloudKit/Internal/Triggers.swift | 14 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 60 ++++---- .../SQLiteData/CloudKit/SyncMetadata.swift | 4 +- .../Documentation.docc/Articles/CloudKit.md | 81 +++++----- .../Articles/PreparingDatabase.md | 2 +- .../Documentation.docc/SQLiteData.md | 10 +- .../SQLiteData/Internal/UserDatabase.swift | 4 +- .../CloudKitTests/AccountLifecycleTests.swift | 1 - .../CloudKitTests/CloudKitTests.swift | 142 ++---------------- .../CloudKitTests/RecordTypeTests.swift | 95 ++---------- .../CloudKitTests/SharingTests.swift | 2 - .../Internal/BaseCloudKitTests.swift | 2 +- 18 files changed, 134 insertions(+), 325 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index adf158ba..6d7571bf 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5ded5ba49617fcf43253f921c393a9829acb4bd0620c1d273ad236940406de92", + "originHash" : "22fb924569f92610b5675a628f98b8864244fe7f2f1702deb956f693c2598118", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "14f79c6dad72e385c564c66dbe333522a11eaa48", - "version" : "0.16.0" + "revision" : "adad5c6c5abe0c62f93c573de5be071043f621a8", + "version" : "0.17.0" } }, { diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index e4327c1b..c84dc4e8 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,7 +1,7 @@ import CasePaths import CloudKit -import Sharing import SQLiteData +import Sharing import SwiftUI import SwiftUINavigation diff --git a/README.md b/README.md index 0f0906eb..0616e2fe 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ var items @FetchAll(Item.order(by: \.isInStock)) var items - + @FetchOne(Item.count()) var itemsCount = 0 diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 54bbed07..fa347e46 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -46,7 +46,7 @@ "The record could not be shared." } } - + /// Shares a record in CloudKit. /// /// This method will thrown an error if: @@ -193,7 +193,7 @@ ) try result?.deleteResults.values.forEach { _ = try $0.get() } } - + /// Accepts a shared record. /// /// This method should be invoked from various delegate methods on the scene delegate of the @@ -203,7 +203,7 @@ } } -#if canImport(UIKit) && !os(watchOS) + #if canImport(UIKit) && !os(watchOS) /// A view that presents standard screens for adding and removing people from a CloudKit share \ /// record. /// diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index 46538ce2..e1912ad7 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -6,7 +6,7 @@ func defaultMetadatabase( logger: Logger, url: URL - ) throws -> any DatabaseReader { + ) throws -> any DatabaseWriter { var configuration = Configuration() configuration.prepareDatabase { [logger] db in db.trace { @@ -43,12 +43,11 @@ configuration: configuration ) } - // TODO: go towards idempotent migrations instead of GRDB migrator by the end of all of this + return metadatabase + } + + func metadatabaseMigrator() -> DatabaseMigrator { var migrator = DatabaseMigrator() - // TODO: do we want this? - #if DEBUG - migrator.eraseDatabaseOnSchemaChange = true - #endif migrator.registerMigration("Create Metadata Tables") { db in try #sql( """ @@ -116,8 +115,6 @@ """ ) .execute(db) - } - migrator.registerMigration("Create PendingRecordZoneChanges Table") { db in try #sql( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( @@ -127,7 +124,6 @@ ) .execute(db) } - try migrator.migrate(metadatabase) - return metadatabase + return migrator } #endif diff --git a/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift index 7852d6be..6525bd09 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift @@ -1,8 +1,8 @@ @Table("sqlite_schema") -struct SQLiteSchema { - let type: String - let name: String +package struct SQLiteSchema { + package let type: String + package let name: String @Column("tbl_name") - let tableName: String - let sql: String? + package let tableName: String + package let sql: String? } diff --git a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift index 0cf5a6e6..5deeae2e 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift @@ -136,7 +136,8 @@ private enum ParentSyncMetadata: AliasName {} - fileprivate static func afterInsertTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger { + fileprivate static func afterInsertTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger + { createTemporaryTrigger( "after_insert_on_sqlitedata_icloud_metadata", ifNotExists: true, @@ -155,7 +156,8 @@ ) } - fileprivate static func afterUpdateTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger { + fileprivate static func afterUpdateTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger + { createTemporaryTrigger( "after_update_on_sqlitedata_icloud_metadata", ifNotExists: true, @@ -165,7 +167,7 @@ syncEngine.$didUpdate( recordName: new.recordName, record: new.lastKnownServerRecord - ?? rootServerRecord(recordName: new.recordName) + ?? rootServerRecord(recordName: new.recordName) ) ) } when: { old, new in @@ -174,7 +176,9 @@ ) } - fileprivate static func afterSoftDeleteTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger { + fileprivate static func afterSoftDeleteTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger< + Self + > { createTemporaryTrigger( "after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, @@ -183,7 +187,7 @@ syncEngine.$didDelete( recordName: new.recordName, record: new.lastKnownServerRecord - ?? rootServerRecord(recordName: new.recordName), + ?? rootServerRecord(recordName: new.recordName), share: new.share ) ) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 0b0866ed..0f65e04e 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -14,7 +14,7 @@ public final class SyncEngine: Sendable { package let userDatabase: UserDatabase package let logger: Logger - package let metadatabase: any DatabaseReader + package let metadatabase: any DatabaseWriter package let tables: [any PrimaryKeyedTable.Type] package let privateTables: [any PrimaryKeyedTable.Type] let tablesByName: [String: any PrimaryKeyedTable.Type] @@ -237,6 +237,16 @@ @TaskLocal package static var _isSynchronizingChanges = false nonisolated package func setUpSyncEngine() throws { + let migrator = metadatabaseMigrator() + // TODO: figure this out + // #if DEBUG + // try metadatabase.read { db in + // let hasSchemaChanges = try migrator.hasSchemaChanges(db) + // precondition(!hasSchemaChanges, "") + // } + // #endif + try migrator.migrate(metadatabase) + try userDatabase.write { db in let attachedMetadatabasePath: String? = try #sql( @@ -336,9 +346,13 @@ try RecordType.all.fetchAll(db) } let currentRecordTypes = try userDatabase.read { db in - let namesAndSchemas = try SQLiteSchema.all + let namesAndSchemas = + try SQLiteSchema + .where { + $0.type.eq("table") + && $0.tableName.in(tables.map { $0.tableName }) + } .fetchAll(db) - .filter { $0.type == "table" } return try namesAndSchemas.compactMap { schema -> RecordType? in guard let sql = schema.sql else { return nil } @@ -349,8 +363,6 @@ ) } } - // TODO: don't update record type if migration wasn't successful - defer { cacheUserTables(recordTypes: currentRecordTypes) } let previousRecordTypeByTableName = Dictionary( uniqueKeysWithValues: previousRecordTypes.map { ($0.tableName, $0) @@ -373,17 +385,16 @@ previousRecordTypeByTableName: previousRecordTypeByTableName, currentRecordTypeByTableName: currentRecordTypeByTableName ) + try await cacheUserTables(recordTypes: currentRecordTypes) } } } - private func cacheUserTables(recordTypes: [RecordType]) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in - try RecordType - .upsert { recordTypes.map { RecordType.Draft($0) } } - .execute(db) - } + private func cacheUserTables(recordTypes: [RecordType]) async throws { + try await userDatabase.write { db in + try RecordType + .upsert { recordTypes.map { RecordType.Draft($0) } } + .execute(db) } } @@ -480,7 +491,7 @@ package func tearDownSyncEngine() throws { try userDatabase.write { db in - for table in tables { + for table in tables.reversed() { try table.dropTriggers(db: db) } for trigger in SyncMetadata.callbackTriggers(for: self).reversed() { @@ -491,12 +502,8 @@ db.remove(function: $didUpdate) db.remove(function: $syncEngineIsSynchronizingChanges) db.remove(function: $datetime) - // TODO: Do an `.erase()` + re-migrate - try SyncMetadata.delete().execute(db) - try RecordType.delete().execute(db) - try StateSerialization.delete().execute(db) - try UnsyncedRecordID.delete().execute(db) } + try metadatabase.erase() } func deleteLocalData() throws { @@ -885,7 +892,7 @@ let (metadataOfDeletions, recordsWithRoot): ([SyncMetadata], [RecordWithRoot]) = await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.read { db in + try await metadatabase.read { db in let metadataOfDeletions = try SyncMetadata.where { $0.recordName.in(deletedRecordNames) } @@ -1381,13 +1388,7 @@ } private func cacheShare(_ share: CKShare) async throws { - guard - let metadata = try? await container.shareMetadata(for: share, shouldFetchRootRecord: false) - else { - // TODO: should we delete this record if it doesn't exist in the container? - return - } - + let metadata = try await container.shareMetadata(for: share, shouldFetchRootRecord: false) guard let rootRecordID = metadata.hierarchicalRootRecordID else { return } try await userDatabase.write { db in @@ -1479,12 +1480,9 @@ ) } - // TODO: Append more ON CONFLICT clauses for each unique constraint? - // TODO: Use WHERE to scope the update? try userDatabase.write { db in do { - let query = upsert(T.self, record: serverRecord, columnNames: columnNames) - try #sql(query).execute(db) + try #sql(upsert(T.self, record: serverRecord, columnNames: columnNames)).execute(db) try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db) try SyncMetadata .where { $0.recordName.eq(serverRecord.recordID.recordName) } @@ -1817,7 +1815,6 @@ } try userDatabase.read { db in for (tableName, foreignKeys) in foreignKeysByTableName { - let invalidForeignKey = foreignKeys.first(where: { tablesByName[$0.table] == nil }) if let invalidForeignKey { throw SyncEngine.SchemaError( @@ -1919,7 +1916,6 @@ else { return } guard !marked.contains(table) else { - // TODO: Can possibly allow cycles by assigning all elements in the cycle the same level and forcing "DELETE CASCADE" on the relationships. struct CycleError: Error {} throw CycleError() } diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 2c676081..316aa9fc 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -8,7 +8,7 @@ /// application is the number of rows this one single table holds. However, this table is held /// in a database separate from your app's database. /// -/// See for more info. + /// See for more info. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Table("sqlitedata_icloud_metadata") public struct SyncMetadata: Hashable, Sendable { @@ -131,7 +131,7 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { + extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { /// A query for finding the metadata associated with a record. /// /// - Parameter primaryKey: The primary key of the record whose metadata to look up. diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index cfdfa26e..8b7356d9 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -133,7 +133,7 @@ versions of your app. There are a number of principles to keep in mind while des your schema to make sure every device can synchronize changes to every other device, no matter the version. -#### Primary keys +#### Globally unique primary keys > TL;DR: Primary keys should be globally unique identifiers, such as UUID. We further recommend > specifying a `NOT NULL` constraint with a `ON CONFLICT REPLACE` action. @@ -184,7 +184,7 @@ CREATE TABLE "reminders" ( ) ``` -Registering custom database functions for ID generation also makes it possible to generate +Registering custom database functions for ID generation also makes it possible to generate deterministic IDs for tests, making it easier to test your queries. #### Primary keys on every table @@ -208,6 +208,22 @@ CREATE TABLE "reminderTags" ( Note that the `id` column might not be needed for your application's logic, but it is necessary to facilitate synchronizing to CloudKit. +#### Foreign key relationships + +> TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to +> cascade deletions. + +SQLiteData can synchronize many-to-one and many-to-many relationships to CloudKit, +and you can enforce foreign key constraints in your database connection. While it is possible for +the sync engine to receive records in an order that could cause a foreign key constraint failure, +such as receiving a child record before its parent, the sync engine will cache the child record +until the parent record has been synchronized, at which point the child record will also be +synchronized. + +Currently the only actions supported for `ON DELETE` are `CASCADE`, `SET NULL` and `SET DEFAULT`. +In particular, `RESTRICT` and `NO ACTION` are not supported, and if you try to use those actions +in your schema an error will be thrown when constructing ``SyncEngine``. + #### Uniqueness constraints > TL;DR: SQLite tables cannot have `UNIQUE` constraints on their columns in order to allow @@ -224,12 +240,12 @@ For this reason uniqueness constraints are not allowed in schemas, and this will when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be thrown. -Sometimes it is possible to make the column that you want to be unique into the primary key of +Sometimes it is possible to make the column that you want to be unique into the primary key of your table. For example, tags with a unique title could be modeled like so: ```swift @Table struct Tag { - let title: String + let title: String } // CREATE TABLE "tags" ( // "title" TEXT NOT NULL PRIMARY KEY @@ -251,37 +267,6 @@ important caveats to be aware of: [CKRecord.ID]: https://developer.apple.com/documentation/cloudkit/ckrecord/id -#### Foreign key relationships - -> TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to -> cascade deletions. - -SQLiteData can synchronize many-to-one and many-to-many relationships to CloudKit, -and you can enforce foreign key constraints in your database connection. While it is possible for -the sync engine to receive records in an order that could cause a foreign key constraint failure, -such as receiving a child record before its parent, the sync engine will cache the child record -until the parent record has been synchronized, at which point the child record will also be -synchronized. - -Currently the only actions supported for `ON DELETE` are `CASCADE`, `SET NULL` and `SET DEFAULT`. -In particular, `RESTRICT` and `NO ACTION` are not supported, and if you try to use those actions -in your schema an error will be thrown when constructing ``SyncEngine``. - -## Record conflicts - -> TL;DR: Conflicts are handled automatically using a "last edit wins" strategy for each -> column of the record. - -Conflicts between record edits will inevitably happen, and it's just a fact of dealing with -distributed data. The library handles conflicts automatically, but does so with a single strategy -that is currently not customizable. When a column is edited on a record, the library keeps track -of the timestamp for that particular column. When merging two conflicting records, each column -is analyzed, and the column that was most recently edited will win over the older data. - -We do not employ more advanced merge conflict strategies, such as CRDT synchronization. We may -allow for these kinds of strategies in the future, but for now "field-wise last edit wins" is -the only strategy available and we feel serves the needs of the most number of people. - ## Backwards compatible migrations > TL;DR: Database migrations should be done carefully and with full backwards compatibility @@ -439,6 +424,21 @@ devices. They are: * Renaming columns * Renaming tables +## Record conflicts + +> TL;DR: Conflicts are handled automatically using a "last edit wins" strategy for each +> column of the record. + +Conflicts between record edits will inevitably happen, and it's just a fact of dealing with +distributed data. The library handles conflicts automatically, but does so with a single strategy +that is currently not customizable. When a column is edited on a record, the library keeps track +of the timestamp for that particular column. When merging two conflicting records, each column +is analyzed, and the column that was most recently edited will win over the older data. + +We do not employ more advanced merge conflict strategies, such as CRDT synchronization. We may +allow for these kinds of strategies in the future, but for now "field-wise last edit wins" is +the only strategy available and we feel serves the needs of the most number of people. + ## Sharing records with other iCloud users SQLiteData provides the tools necessary to share a record with another iCloud user so that @@ -585,9 +585,11 @@ var rows Here we have used the ``StructuredQueriesCore/PrimaryKeyedTableDefinition/recordName`` helper that is defined on all primary key tables so that we can join ``SyncMetadata`` to `RemindersList`. + +todo: finish +--> ## Unit testing and Xcode previews @@ -659,8 +661,6 @@ And in preivews you can use it like so: - - ## Migrating from Swift Data to SQLiteData ## Separating schema migrations from data migrations @@ -714,6 +714,11 @@ Model.createTemporaryTrigger( This will skip the trigger's action when the row is being updated due to data being synchronized from CloudKit. +### Developing in the simulator + + + + ## Topics ### Go deeper diff --git a/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md index 07c87d7e..ccf7b0cb 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md @@ -46,7 +46,7 @@ data: + configuration.foreignKeysEnabled = true } ``` - + This will prevent you from deleting rows that leave other rows with invalid associations. For example, if a "reminders" table had an association to a "remindersLists" table, you would not be allowed to delete a list row unless there were no reminders associated with it, or if you had diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 86d76c13..6c6c4b7c 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -117,12 +117,12 @@ This `defaultDatabase` connection is used implicitly by SQLiteData's property wr @FetchAll(Item.where(\.isInStock)) var items - - - + + + @FetchAll(Item.order(by: \.isInStock)) var items - + @FetchOne(Item.count()) var itemsCount = 0 ``` @@ -139,7 +139,7 @@ This `defaultDatabase` connection is used implicitly by SQLiteData's property wr $0.isInStock }) var items: [Item] - + // No @Query equivalent of ordering // by boolean column. diff --git a/Sources/SQLiteData/Internal/UserDatabase.swift b/Sources/SQLiteData/Internal/UserDatabase.swift index 3fd2e24f..96ca2a2a 100644 --- a/Sources/SQLiteData/Internal/UserDatabase.swift +++ b/Sources/SQLiteData/Internal/UserDatabase.swift @@ -53,9 +53,7 @@ package struct UserDatabase { _ updates: (Database) throws -> T ) throws -> T { try database.read { db in - try SyncEngine.$_isSynchronizingChanges.withValue(true) { - try updates(db) - } + try updates(db) } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index 73c2f64a..ad47b160 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -122,7 +122,6 @@ } } - // TODO: look into if there are more tests to write here @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func doNotUploadExistingDataToCloudKitWhenSignedOut() { } diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index 1717e31e..89d9a155 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -13,7 +13,7 @@ final class CloudKitTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() throws { - let zones = try userDatabase.userRead { db in + let zones = try syncEngine.metadatabase.read { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: zones, as: .customDump) { @@ -45,26 +45,6 @@ ] ), [1]: RecordType( - tableName: "sqlite_sequence", - schema: "CREATE TABLE sqlite_sequence(name,seq)", - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "name", - notNull: false, - type: "" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "seq", - notNull: false, - type: "" - ) - ] - ), - [2]: RecordType( tableName: "remindersListAssets", schema: """ CREATE TABLE "remindersListAssets" ( @@ -97,7 +77,7 @@ ) ] ), - [3]: RecordType( + [2]: RecordType( tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( @@ -130,7 +110,7 @@ ) ] ), - [4]: RecordType( + [3]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( @@ -189,7 +169,7 @@ ) ] ), - [5]: RecordType( + [4]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( @@ -206,7 +186,7 @@ ) ] ), - [6]: RecordType( + [5]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( @@ -239,7 +219,7 @@ ) ] ), - [7]: RecordType( + [6]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( @@ -256,7 +236,7 @@ ) ] ), - [8]: RecordType( + [7]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -281,7 +261,7 @@ ) ] ), - [9]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( @@ -307,40 +287,7 @@ ) ] ), - [10]: RecordType( - tableName: "localUsers", - schema: """ - CREATE TABLE "localUsers" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, - name: "name", - notNull: true, - type: "TEXT" - ), - [2]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "parentID", - notNull: false, - type: "INTEGER" - ) - ] - ), - [11]: RecordType( + [9]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( @@ -366,7 +313,7 @@ ) ] ), - [12]: RecordType( + [10]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( @@ -399,7 +346,7 @@ ) ] ), - [13]: RecordType( + [11]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( @@ -431,72 +378,12 @@ type: "TEXT" ) ] - ), - [14]: RecordType( - tableName: "unsyncedModels", - schema: """ - CREATE TABLE "unsyncedModels" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL - ) - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ) - ] ) ] """# } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func tearDown() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - } - } - 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: [] - ) - ) - """ - } - - try await userDatabase.userRead { db in - let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 - #expect(metadataCount == 1) - } - try syncEngine.tearDownSyncEngine() - try await self.userDatabase.userRead { db in - let metadataCount = try SyncMetadata.count().fetchOne(db) ?? 0 - #expect(metadataCount == 0) - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDownAndReSetUp() async throws { try syncEngine.tearDownSyncEngine() @@ -575,11 +462,8 @@ [] """ } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func migration() async throws { - // TODO: how to test what happens after a migration? need to assert that zones are fetched. + try syncEngine.setUpSyncEngine() } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -1081,7 +965,5 @@ #expect(modelA.isEven == true) } } - - // TODO: Test what happens when we delete locally and then an edit comes in from the server } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index c25321aa..69a2dbf9 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -44,26 +44,6 @@ ] ), [1]: RecordType( - tableName: "sqlite_sequence", - schema: "CREATE TABLE sqlite_sequence(name,seq)", - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "name", - notNull: false, - type: "" - ), - [1]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "seq", - notNull: false, - type: "" - ) - ] - ), - [2]: RecordType( tableName: "remindersListAssets", schema: """ CREATE TABLE "remindersListAssets" ( @@ -96,7 +76,7 @@ ) ] ), - [3]: RecordType( + [2]: RecordType( tableName: "remindersListPrivates", schema: """ CREATE TABLE "remindersListPrivates" ( @@ -129,7 +109,7 @@ ) ] ), - [4]: RecordType( + [3]: RecordType( tableName: "reminders", schema: """ CREATE TABLE "reminders" ( @@ -188,7 +168,7 @@ ) ] ), - [5]: RecordType( + [4]: RecordType( tableName: "tags", schema: """ CREATE TABLE "tags" ( @@ -205,7 +185,7 @@ ) ] ), - [6]: RecordType( + [5]: RecordType( tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( @@ -238,7 +218,7 @@ ) ] ), - [7]: RecordType( + [6]: RecordType( tableName: "parents", schema: """ CREATE TABLE "parents"( @@ -255,7 +235,7 @@ ) ] ), - [8]: RecordType( + [7]: RecordType( tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( @@ -280,7 +260,7 @@ ) ] ), - [9]: RecordType( + [8]: RecordType( tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( @@ -306,40 +286,7 @@ ) ] ), - [10]: RecordType( - tableName: "localUsers", - schema: """ - CREATE TABLE "localUsers" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE - ) STRICT - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ), - [1]: TableInfo( - defaultValue: "\'\'", - isPrimaryKey: false, - name: "name", - notNull: true, - type: "TEXT" - ), - [2]: TableInfo( - defaultValue: nil, - isPrimaryKey: false, - name: "parentID", - notNull: false, - type: "INTEGER" - ) - ] - ), - [11]: RecordType( + [9]: RecordType( tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( @@ -365,7 +312,7 @@ ) ] ), - [12]: RecordType( + [10]: RecordType( tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( @@ -398,7 +345,7 @@ ) ] ), - [13]: RecordType( + [11]: RecordType( tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( @@ -430,23 +377,6 @@ type: "TEXT" ) ] - ), - [14]: RecordType( - tableName: "unsyncedModels", - schema: """ - CREATE TABLE "unsyncedModels" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL - ) - """, - tableInfo: [ - [0]: TableInfo( - defaultValue: nil, - isPrimaryKey: true, - name: "id", - notNull: true, - type: "INTEGER" - ) - ] ) ] """# @@ -456,9 +386,10 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tearDown() async throws { try syncEngine.tearDownSyncEngine() - try await userDatabase.userRead { db in - try #expect(RecordType.all.fetchAll(db) == []) + try await syncEngine.metadatabase.read { db in + try #expect(SQLiteSchema.all.fetchCount(db) == 0) } + try syncEngine.setUpSyncEngine() } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index ae47e15a..a43fe6da 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -904,6 +904,4 @@ } } } - -// TODO: Assert on Metadata.parentRecordName when create new reminders in a shared list #endif diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 9c80b949..198145d7 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -123,7 +123,7 @@ class BaseCloudKitTests: @unchecked Sendable { syncEngine.private.state.assertPendingDatabaseChanges([]) syncEngine.private.state.assertPendingRecordZoneChanges([]) syncEngine.private.assertAcceptedShareMetadata([]) - try! userDatabase.read { db in + try! syncEngine.metadatabase.read { db in try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) } } else { From 14370ea2bef35fdf7268496617c558de147d7e3a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 4 Sep 2025 11:36:04 -0700 Subject: [PATCH 536/581] CloudKit docs organization (#158) * wip * wip * wip --- Makefile | 2 +- .../IdentifierStringConvertible.swift | 3 ++ .../CloudKit/Internal/Triggers.swift | 1 - .../Documentation.docc/Articles/CloudKit.md | 13 +++------ .../Articles/CloudKitSharing.md | 2 -- .../Documentation.docc/Extensions/Database.md | 16 ++++++++++ .../Extensions/IdentifierStringConvertible.md | 29 +++++++++++++++++++ .../Extensions/SystemFieldsRepresentation.md | 1 + .../Documentation.docc/SQLiteData.md | 28 ++++++++++-------- 9 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 Sources/SQLiteData/Documentation.docc/Extensions/Database.md create mode 100644 Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md create mode 100644 Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md diff --git a/Makefile b/Makefile index d23c3fc0..f80e8f17 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ XCODEBUILD_FLAGS = \ XCODEBUILD_COMMAND = xcodebuild $(XCODEBUILD_ARGUMENT) $(XCODEBUILD_FLAGS) -# TODO: Prefer 'xcbeautify --quiet' when this is fixed: +# NB: Prefer 'xcbeautify --quiet' when this is fixed: # https://github.com/cpisciotta/xcbeautify/issues/339 ifneq ($(strip $(shell which xcbeautify)),) XCODEBUILD = set -o pipefail && $(XCODEBUILD_COMMAND) | xcbeautify diff --git a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift index 54243d39..259aa14c 100644 --- a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift +++ b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift @@ -1,6 +1,9 @@ import Foundation /// A type that can be represented by a string identifier. +/// +/// A requirement of tables synchronized to CloudKit using a ``SyncEngine``. You should generally +/// identify tables using Foundation's `UUID` type. public protocol IdentifierStringConvertible { init?(rawIdentifier: String) var rawIdentifier: String { get } diff --git a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift index 5deeae2e..c2fbe727 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift @@ -51,7 +51,6 @@ ifNotExists: true, after: .update { _, new in checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) - // TODO: change to update? SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } ) diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 8b7356d9..0d3ca7b5 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -1,4 +1,4 @@ -# CloudKit synchronization +# Getting started with CloudKit Learn how to seamlessly add CloudKit synchronization to your SQLiteData application. @@ -714,13 +714,8 @@ Model.createTemporaryTrigger( This will skip the trigger's action when the row is being updated due to data being synchronized from CloudKit. + - - -## Topics - -### Go deeper - -- +TODO: talk about simulator push restrictions +--> \ No newline at end of file diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md index f599c68d..01e5e32e 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md @@ -471,5 +471,3 @@ struct MyApp: App { This table will still be synchronized across all of a single user's devices, but if that user shares a list with a friend, it will _not_ share the private table, allowing each user to have their own personal ordering of lists. - -## Querying share metadata diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/Database.md b/Sources/SQLiteData/Documentation.docc/Extensions/Database.md new file mode 100644 index 00000000..8c8f2316 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Extensions/Database.md @@ -0,0 +1,16 @@ +# ``GRDB/Database`` + +## Topics + +### Seeding model data + +- ``seed(_:)`` + +### User-defined functions + +- ``add(function:)`` +- ``remove(function:)`` + +### Querying CloudKit metadata + +- ``attachMetadatabase(containerIdentifier:)`` diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md b/Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md new file mode 100644 index 00000000..15411423 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Extensions/IdentifierStringConvertible.md @@ -0,0 +1,29 @@ +# ``IdentifierStringConvertible`` + +## Topics + +### Conformances + +- ``Swift/Bool`` +- ``Swift/Character`` +- ``Swift/Double`` +- ``Swift/Float`` +- ``Swift/Float16`` +- ``Swift/Int`` +- ``Swift/Int128`` +- ``Swift/Int16`` +- ``Swift/Int32`` +- ``Swift/Int64`` +- ``Swift/Int8`` +- ``Swift/String`` +- ``Swift/Substring`` +- ``Swift/UInt`` +- ``Swift/UInt128`` +- ``Swift/UInt16`` +- ``Swift/UInt32`` +- ``Swift/UInt64`` +- ``Swift/UInt8`` +- ``Swift/Optional`` +- ``Swift/Unicode/Scalar`` +- ``Foundation/UUID`` +- ``Tagged/Tagged`` diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md b/Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md new file mode 100644 index 00000000..ccf259bf --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Extensions/SystemFieldsRepresentation.md @@ -0,0 +1 @@ +# ``CloudKit/CKRecord/SystemFieldsRepresentation`` diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 6c6c4b7c..958a71c6 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -277,27 +277,31 @@ with SQLite to take full advantage of GRDB and SQLiteData. - - - -- - ### Database configuration and access +- ``GRDB/Database`` - ``Dependencies/DependencyValues/defaultDatabase`` -### Fetching and observing queries +### Querying model data + +- ``StructuredQueriesCore/Statement`` +- ``StructuredQueriesCore/SelectStatement`` +- ``QueryCursor`` + +### Observing model data - ``FetchAll`` - ``FetchOne`` - ``Fetch`` -### Executing statements +### CloudKit synchronization and sharing -- ``StructuredQueriesCore/Statement/execute(_:)`` -- ``StructuredQueriesCore/Statement/fetchAll(_:)`` -- ``StructuredQueriesCore/Statement/fetchOne(_:)`` -- ``StructuredQueriesCore/Statement/fetchCursor(_:)`` -- ``StructuredQueriesCore/SelectStatement/fetchCount(_:)`` - -### Seeding data - -- ``GRDB/Database/seed(_:)`` +- +- +- ``SyncEngine`` +- ``Dependencies/DependencyValues/defaultSyncEngine`` +- ``IdentifierStringConvertible`` +- ``SyncMetadata`` +- ``SharedRecord`` From 3433038e0eab08ecb6155815a9f03663a6fbf43b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 4 Sep 2025 11:53:01 -0700 Subject: [PATCH 537/581] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 0f65e04e..48a8bb4b 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -152,7 +152,7 @@ database: container.privateCloudDatabase, stateSerialization: try? metadatabase.read { db in try StateSerialization - .find(BindQueryExpression(CKDatabase.Scope.private)) + .find(#bind(.private)) .select(\.data) .fetchOne(db) }, @@ -164,7 +164,7 @@ database: container.sharedCloudDatabase, stateSerialization: try? metadatabase.read { db in try StateSerialization - .find(BindQueryExpression(CKDatabase.Scope.shared)) + .find(#bind(.shared)) .select(\.data) .fetchOne(db) }, @@ -238,13 +238,12 @@ nonisolated package func setUpSyncEngine() throws { let migrator = metadatabaseMigrator() - // TODO: figure this out - // #if DEBUG - // try metadatabase.read { db in - // let hasSchemaChanges = try migrator.hasSchemaChanges(db) - // precondition(!hasSchemaChanges, "") - // } - // #endif + #if DEBUG + try metadatabase.read { db in + let hasSchemaChanges = try migrator.hasSchemaChanges(db) + assert(!hasSchemaChanges, "Metadatabase migrations must not be modified after release") + } + #endif try migrator.migrate(metadatabase) try userDatabase.write { db in From 8bb1037cf2413160550889f4a0ed98def9a6b843 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 4 Sep 2025 11:57:12 -0700 Subject: [PATCH 538/581] wip --- Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift | 6 +++++- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift index 6525bd09..ed08fc28 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SQLiteSchema.swift @@ -1,8 +1,12 @@ @Table("sqlite_schema") package struct SQLiteSchema { - package let type: String + package let type: ObjectType package let name: String @Column("tbl_name") package let tableName: String package let sql: String? + + package enum ObjectType: String, QueryBindable { + case table, index, view, trigger + } } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 48a8bb4b..4fd50c30 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -348,7 +348,7 @@ let namesAndSchemas = try SQLiteSchema .where { - $0.type.eq("table") + $0.type.eq(#bind(.table)) && $0.tableName.in(tables.map { $0.tableName }) } .fetchAll(db) From 900ec106419054eb3a8e0a922c94d8a4e1d7a582 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 4 Sep 2025 12:02:18 -0700 Subject: [PATCH 539/581] wip --- Examples/Reminders/Schema.swift | 5 +++++ Examples/RemindersTests/Internal.swift | 5 +---- Examples/SyncUps/Schema.swift | 7 ------- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 73bcbbbf..31b0bbef 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -108,6 +108,11 @@ extension DependencyValues { Tag.self, ReminderTag.self ) + if context != .live { + try defaultDatabase.write { db in + try db.seedSampleData() + } + } } } diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index a58ed92b..b21916c3 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -10,10 +10,7 @@ import Testing .dependency(\.continuousClock, ImmediateClock()), .dependency(\.date.now, Date(timeIntervalSince1970: 1_234_567_890)), .dependency(\.uuid, .incrementing), - .dependencies { - $0.defaultDatabase = try Reminders.appDatabase() - try $0.defaultDatabase.write { try $0.seedSampleData() } - }, + .dependencies { try $0.bootstrapDatabase() }, .snapshots(record: .failed) ) struct BaseTestSuite {} diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index 2a186271..83196a56 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -145,13 +145,6 @@ func appDatabase() throws -> any DatabaseWriter { } try migrator.migrate(database) - - if context == .preview { - try database.write { db in - try db.seedSampleData() - } - } - return database } From 11e8e916925b14d772e3368d356c5c718decfc89 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 4 Sep 2025 12:17:34 -0700 Subject: [PATCH 540/581] wip --- Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift | 2 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 0593f837..59099e60 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -165,7 +165,7 @@ package final class MockCloudDatabase: CloudDatabase { } } - // TODO: this should merge copy's values into storage but not sure how right now. + // TODO: This should merge copy's values to more accurately reflect reality storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy saveResults[recordToSave.recordID] = .success(copy) } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 4fd50c30..f420334b 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -323,7 +323,6 @@ } } - // TODO: Should we make isRunning observable? /// Determines if the sync engine is currently running or not. public var isRunning: Bool { syncEngines.withValue { From 0d6f5048ae7ff0e793871812c84efbbd5a0bb1a2 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 4 Sep 2025 17:11:33 -0700 Subject: [PATCH 541/581] Add observation to `SyncEngine.isRunning` (#159) * Add observation to `SyncEngine.isRunning` * wip * wip * wip * wip --- Examples/Examples.xcodeproj/project.pbxproj | 37 ++++++++++--------- .../xcshareddata/swiftpm/Package.resolved | 6 +-- Examples/Reminders/RemindersLists.swift | 4 -- Examples/Reminders/Schema.swift | 36 ++++++++---------- Examples/RemindersTests/Reminders.xctestplan | 2 +- .../RemindersTests/RemindersListsTests.swift | 9 +++-- Sources/SQLiteData/CloudKit/SyncEngine.swift | 27 +++++++++----- 7 files changed, 61 insertions(+), 60 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 2db7d786..a7d7bc4c 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -16,9 +16,9 @@ CA6A1D242E68A0A600604D6A /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA6A1D232E68A0A600604D6A /* SQLiteData */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; + DC9A3DDE2E6A280700DE41FB /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = DC9A3DDD2E6A280700DE41FB /* SQLiteData */; }; + DC9A3DE02E6A280F00DE41FB /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = DC9A3DDF2E6A280F00DE41FB /* SQLiteData */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; - DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8A2E02176700FB20F8 /* SharingGRDB */; }; - DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8E2E02177900FB20F8 /* SharingGRDB */; }; DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCF267382D48437300B680BE /* SwiftUINavigation */; }; /* End PBXBuildFile section */ @@ -147,7 +147,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */, + DC9A3DDE2E6A280700DE41FB /* SQLiteData in Frameworks */, CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -175,7 +175,7 @@ files = ( DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */, DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */, - DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */, + DC9A3DE02E6A280F00DE41FB /* SQLiteData in Frameworks */, DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -289,7 +289,7 @@ name = CaseStudies; packageProductDependencies = ( CA2908C82D4AF70E003F165F /* UIKitNavigation */, - DCD9AC8A2E02176700FB20F8 /* SharingGRDB */, + DC9A3DDD2E6A280700DE41FB /* SQLiteData */, ); productName = Examples; productReference = CAF836982D4735620047AEB5 /* CaseStudies.app */; @@ -363,7 +363,7 @@ DCBE8A132D4842BF0071F499 /* CasePaths */, DCF267382D48437300B680BE /* SwiftUINavigation */, DC5FA7472D4C63D60082743E /* DependenciesMacros */, - DCD9AC8E2E02177900FB20F8 /* SharingGRDB */, + DC9A3DDF2E6A280F00DE41FB /* SQLiteData */, ); productName = SyncUps; productReference = DCBE89CC2D483FB90071F499 /* SyncUps.app */; @@ -416,7 +416,7 @@ DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, - DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */, + DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -1004,9 +1004,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */ = { + DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */ = { isa = XCLocalSwiftPackageReference; - relativePath = "../../sharing-grdb"; + relativePath = ".."; }; /* End XCLocalSwiftPackageReference section */ @@ -1078,7 +1078,7 @@ }; CA6A1D232E68A0A600604D6A /* SQLiteData */ = { isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; + package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */; productName = SQLiteData; }; CAD001862D874F1F00FA977A /* DependenciesTestSupport */ = { @@ -1091,19 +1091,20 @@ package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesMacros; }; - DCBE8A132D4842BF0071F499 /* CasePaths */ = { + DC9A3DDD2E6A280700DE41FB /* SQLiteData */ = { isa = XCSwiftPackageProductDependency; - package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; - productName = CasePaths; + package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */; + productName = SQLiteData; }; - DCD9AC8A2E02176700FB20F8 /* SharingGRDB */ = { + DC9A3DDF2E6A280F00DE41FB /* SQLiteData */ = { isa = XCSwiftPackageProductDependency; - productName = SharingGRDB; + package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference ".." */; + productName = SQLiteData; }; - DCD9AC8E2E02177900FB20F8 /* SharingGRDB */ = { + DCBE8A132D4842BF0071F499 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; - productName = SharingGRDB; + package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; + productName = CasePaths; }; DCF267382D48437300B680BE /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6d7571bf..cdbb400e 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "22fb924569f92610b5675a628f98b8864244fe7f2f1702deb956f693c2598118", + "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", "pins" : [ { "identity" : "combine-schedulers", @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "eefcdaa88d2e2fd82e3405dfb6eb45872011a0b5", - "version" : "1.9.3" + "branch" : "async-dependencies-trait", + "revision" : "08c2d6bd189303138d729f00d97bca4a7247a2c7" } }, { diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index ccde9af6..71303b51 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -192,7 +192,6 @@ class RemindersListsModel { struct RemindersListsView: View { @Bindable var model: RemindersListsModel - @State var id = UUID() @Dependency(\.defaultSyncEngine) var syncEngine var body: some View { @@ -319,13 +318,11 @@ struct RemindersListsView: View { Button { if syncEngine.isRunning { syncEngine.stop() - id = UUID() } else { Task { await withErrorReporting { try await syncEngine.start() } - id = UUID() } } } label: { @@ -387,7 +384,6 @@ struct RemindersListsView: View { .navigationDestination(item: $model.destination.detail) { detailModel in RemindersDetailView(model: detailModel) } - .id(id) } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 31b0bbef..e4308468 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -108,17 +108,11 @@ extension DependencyValues { Tag.self, ReminderTag.self ) - if context != .live { - try defaultDatabase.write { db in - try db.seedSampleData() - } - } } } func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context - let database: any DatabaseWriter var configuration = Configuration() configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in @@ -133,21 +127,17 @@ func appDatabase() throws -> any DatabaseWriter { } #endif } - if context == .preview { - database = try DatabaseQueue(configuration: configuration) - } else { - let path = - context == .live - ? URL.documentsDirectory.appending(component: "db.sqlite").path() - : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - logger.debug( - """ - App database: - open "\(path)" - """ - ) - database = try DatabasePool(path: path, configuration: configuration) - } + let path = + context == .live + ? URL.documentsDirectory.appending(component: "db.sqlite").path() + : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + let database = try DatabasePool(path: path, configuration: configuration) + logger.debug( + """ + App database: + open "\(path)" + """ + ) var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true @@ -326,6 +316,10 @@ func appDatabase() throws -> any DatabaseWriter { } ) .execute(db) + + if context != .live { + try db.seedSampleData() + } } return database diff --git a/Examples/RemindersTests/Reminders.xctestplan b/Examples/RemindersTests/Reminders.xctestplan index 18ccddb6..8f339faf 100644 --- a/Examples/RemindersTests/Reminders.xctestplan +++ b/Examples/RemindersTests/Reminders.xctestplan @@ -20,7 +20,7 @@ "parallelizable" : true, "target" : { "containerPath" : "container:Examples.xcodeproj", - "identifier" : "CA9F99472DF9134D00934431", + "identifier" : "CA5E46952DEBFE410069E0F8", "name" : "RemindersTests" } } diff --git a/Examples/RemindersTests/RemindersListsTests.swift b/Examples/RemindersTests/RemindersListsTests.swift index 45c3560d..d0fbb73b 100644 --- a/Examples/RemindersTests/RemindersListsTests.swift +++ b/Examples/RemindersTests/RemindersListsTests.swift @@ -24,7 +24,8 @@ extension BaseTestSuite { color: 1218047999, position: 1, title: "Personal" - ) + ), + share: nil ), [1]: RemindersListsModel.ReminderListState( remindersCount: 2, @@ -33,7 +34,8 @@ extension BaseTestSuite { color: 3985191935, position: 2, title: "Family" - ) + ), + share: nil ), [2]: RemindersListsModel.ReminderListState( remindersCount: 2, @@ -42,7 +44,8 @@ extension BaseTestSuite { color: 2992493567, position: 3, title: "Business" - ) + ), + share: nil ) ] """ diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f420334b..90e0ba31 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -6,12 +6,13 @@ import GRDB import OrderedCollections import OSLog + import Observation import StructuredQueriesCore import SwiftData /// An object that manages the synchronization of local and remote SQLite data. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public final class SyncEngine: Sendable { + public final class SyncEngine: Observable, Sendable { package let userDatabase: UserDatabase package let logger: Logger package let metadatabase: any DatabaseWriter @@ -27,6 +28,7 @@ -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) package let container: any CloudContainer let dataManager = Dependency(\.dataManager) + private let observationRegistrar = ObservationRegistrar() /// The error message used when a write occurs to a record for which the current user /// does not have permission. @@ -72,7 +74,7 @@ privateTables: repeat (each T2).Type, containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), - startImmediately: Bool = true, + startImmediately: Bool = !isTesting, logger: Logger = isTesting ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") ) throws @@ -318,14 +320,17 @@ /// You must start the sync engine again using ``start()`` to synchronize the changes. public func stop() { guard isRunning else { return } - syncEngines.withValue { - $0 = SyncEngines() + observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { + syncEngines.withValue { + $0 = SyncEngines() + } } } /// Determines if the sync engine is currently running or not. public var isRunning: Bool { - syncEngines.withValue { + observationRegistrar.access(self, keyPath: \.isRunning) + return syncEngines.withValue { $0.isRunning } } @@ -333,11 +338,13 @@ private func start() throws -> Task { guard !isRunning else { return Task {} } let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) - syncEngines.withValue { - $0 = SyncEngines( - private: privateSyncEngine, - shared: sharedSyncEngine - ) + observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { + syncEngines.withValue { + $0 = SyncEngines( + private: privateSyncEngine, + shared: sharedSyncEngine + ) + } } let previousRecordTypes = try metadatabase.read { db in From abba0a73e09e82c1269726f0ae83237f5fac098f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 4 Sep 2025 17:15:36 -0700 Subject: [PATCH 542/581] wip --- .../xcshareddata/swiftpm/Package.resolved | 4 +-- Examples/Reminders/RemindersApp.swift | 2 +- Examples/Reminders/Schema.swift | 27 +++++++++++-------- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cdbb400e..c108a80b 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "branch" : "async-dependencies-trait", - "revision" : "08c2d6bd189303138d729f00d97bca4a7247a2c7" + "revision" : "a501eebe552fd23691c560adf474fca2169ad8aa", + "version" : "1.9.4" } }, { diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index a4222ded..de1cefb8 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -36,7 +36,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - return true + true } func application( diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index e4308468..d2aa1c8d 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -113,6 +113,7 @@ extension DependencyValues { func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context + let database: any DatabaseWriter var configuration = Configuration() configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in @@ -127,17 +128,21 @@ func appDatabase() throws -> any DatabaseWriter { } #endif } - let path = - context == .live - ? URL.documentsDirectory.appending(component: "db.sqlite").path() - : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - let database = try DatabasePool(path: path, configuration: configuration) - logger.debug( - """ - App database: - open "\(path)" - """ - ) + if context == .preview { + database = try DatabaseQueue(configuration: configuration) + } else { + let path = + context == .live + ? URL.documentsDirectory.appending(component: "db.sqlite").path() + : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + logger.debug( + """ + App database: + open "\(path)" + """ + ) + database = try DatabasePool(path: path, configuration: configuration) + } var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 90e0ba31..78c8e150 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -74,7 +74,7 @@ privateTables: repeat (each T2).Type, containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), - startImmediately: Bool = !isTesting, + startImmediately: Bool = DependencyValues._current.context == .live, logger: Logger = isTesting ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") ) throws From b3eaf7a868663edd81d2279b7da369fec9bdd54a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 4 Sep 2025 20:56:46 -0500 Subject: [PATCH 543/581] fix --- .../CloudKit/Internal/ForeignKey.swift | 4 ++-- Sources/SQLiteData/CloudKit/SyncEngine.swift | 19 ++++++++++----- .../CloudKitTests/SyncEngineTests.swift | 23 ++++++++----------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift b/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift index 4dd5461a..4813283f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift +++ b/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift @@ -2,7 +2,7 @@ import Foundation import StructuredQueriesCore - struct ForeignKey: QueryDecodable, QueryRepresentable { + package struct ForeignKey: QueryDecodable, QueryRepresentable { typealias QueryValue = Self let table: String @@ -12,7 +12,7 @@ let onDelete: Action let notnull: Bool - init(decoder: inout some QueryDecoder) throws { + package init(decoder: inout some QueryDecoder) throws { guard let table = try decoder.decode(String.self), let from = try decoder.decode(String.self), diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 78c8e150..ba65213e 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -243,7 +243,14 @@ #if DEBUG try metadatabase.read { db in let hasSchemaChanges = try migrator.hasSchemaChanges(db) - assert(!hasSchemaChanges, "Metadatabase migrations must not be modified after release") + assert( + !hasSchemaChanges, + """ + A previously run migration has been removed or edited. + + Metadatabase migrations must not be modified after release. + """ + ) } #endif try migrator.migrate(metadatabase) @@ -1788,8 +1795,8 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { - struct SchemaError: LocalizedError { - enum Reason { + package struct SchemaError: LocalizedError { + package enum Reason { case inMemoryDatabase case invalidForeignKey(ForeignKey) case invalidForeignKeyAction(ForeignKey) @@ -1800,10 +1807,10 @@ case unknown case uniquenessConstraint } - let reason: Reason - let debugDescription: String + package let reason: Reason + package let debugDescription: String - var errorDescription: String? { + package var errorDescription: String? { "Could not synchronize data with iCloud." } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift index 8131a809..da5efc9b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift @@ -65,13 +65,14 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func metadatabaseMismatch() async throws { - let error = await #expect(throws: (any Error).self) { + let error = await #expect(throws: SyncEngine.SchemaError.self) { var configuration = Configuration() configuration.prepareDatabase { db in try db.attachMetadatabase(containerIdentifier: "iCloud.co.pointfree") } + let path = "/tmp/\(UUID()).sqlite" let database = try DatabasePool( - path: "/tmp/db.sqlite", + path: path, configuration: configuration ) _ = try await SyncEngine( @@ -84,17 +85,13 @@ tables: [] ) } - assertInlineSnapshot(of: error, as: .customDump) { - #""" - SyncEngine.SchemaError( - reason: .metadatabaseMismatch( - attachedPath: "/private/tmp/.db.metadata-iCloud.co.pointfree.sqlite", - syncEngineConfiguredPath: "/tmp/.db.metadata-iCloud.co.point-free.sqlite" - ), - debugDescription: "Metadatabase attached in \'prepareDatabase\' does not match metadatabase prepared in \'SyncEngine.init\'. Are different CloudKit container identifiers being provided?" - ) - """# - } + + #expect( + error?.debugDescription == """ + Metadatabase attached in 'prepareDatabase' does not match metadatabase prepared in \ + 'SyncEngine.init'. Are different CloudKit container identifiers being provided? + """ + ) } } } From ae6fea41df8628c3dcf38ebc598c95499f569760 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 09:29:13 -0500 Subject: [PATCH 544/581] finish test --- .../CloudKitTests/AccountLifecycleTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index ad47b160..e5e601bd 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -6,6 +6,7 @@ import SQLiteData import SnapshotTestingCustomDump import Testing + import SQLiteDataTestSupport extension BaseCloudKitTests { @MainActor @@ -124,6 +125,21 @@ @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: [] + ) + ) + """ + } } } } From 3163c5331d0f8e5aa188a9d161b831744bbac113 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 09:48:59 -0500 Subject: [PATCH 545/581] Add test for cyclic schemas' --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 9 +++- .../SyncEngineValidationTests.swift | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index ba65213e..73a4e84f 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1797,6 +1797,7 @@ extension SyncEngine { package struct SchemaError: LocalizedError { package enum Reason { + case cycleDetected case inMemoryDatabase case invalidForeignKey(ForeignKey) case invalidForeignKeyAction(ForeignKey) @@ -1928,8 +1929,12 @@ else { return } guard !marked.contains(table) else { - struct CycleError: Error {} - throw CycleError() + throw SyncEngine.SchemaError( + reason: .cycleDetected, + debugDescription: """ + Cycles are not currently permitted in schemas, e.g. a table that references itself. + """ + ) } marked.insert(table) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift index 6f3b5f62..c0734ebb 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift @@ -323,6 +323,53 @@ """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func cycleValidation() async throws { + let error = try #require( + await #expect(throws: (any Error).self) { + let database = try DatabaseQueue() + try await database.write { db in + try #sql( + """ + CREATE TABLE "recursiveTables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "parentID" INTEGER REFERENCES "recursiveTables"("id") + ) STRICT + """ + ) + .execute(db) + } + _ = try await SyncEngine( + container: MockCloudContainer( + containerIdentifier: "deadbeef", + privateCloudDatabase: MockCloudDatabase(databaseScope: .private), + sharedCloudDatabase: MockCloudDatabase(databaseScope: .shared) + ), + userDatabase: UserDatabase(database: database), + tables: [RecursiveTable.self] + ) + } + ) + assertInlineSnapshot(of: error.localizedDescription, as: .customDump) { + """ + "Could not synchronize data with iCloud." + """ + } + assertInlineSnapshot(of: error, as: .customDump) { + """ + SyncEngine.SchemaError( + reason: .cycleDetected, + debugDescription: "Cycles are not currently permitted in schemas, e.g. a table that references itself." + ) + """ + } + } } } + + @Table struct RecursiveTable: Identifiable { + let id: Int + let parentID: RecursiveTable.ID? + } #endif From ee9e93416d96f475eaed8a770d002c7713bff248 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 10:26:39 -0500 Subject: [PATCH 546/581] docs on PK migration --- .../Documentation.docc/Articles/CloudKit.md | 92 ++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 0d3ca7b5..e065ce34 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -655,7 +655,95 @@ 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 +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. + +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 +migrator.registerMigration("Convert 'remindersLists' table primary key to UUID") { db in + // Step 1: Create new table with updated schema + try #sql(""" + CREATE TABLE "new_remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + -- all other columns from 'remindersLists' table + ) STRICT + """) + .execute(db) + + // Step 2: Copy data from 'remindersLists' to 'new_remindersLists' and convert integer + // IDs to UUIDs + try #sql(""" + INSERT INTO "new_remindersLists" + ( + "id", + -- all other columns from 'remindersLists' table + ) + SELECT + -- This converts integers to UUIDs, e.g. 1 -> 00000000-0000-0000-0000-000000000001 + '00000000-0000-0000-0000-' || printf('%012x', "id"), + -- all other columns from 'remindersLists' table + FROM "remindersLists" + """) + .execute(db) + + // Step 3: Drop the old 'remindersLists' table + try #sql(""" + DROP TABLE "remindersLists" + """) + .execute(db) + + // Step 4: Rename 'new_remindersLists' to 'remindersLists' + try #sql(""" + ALTER TABLE "new_remindersLists" RENAME TO "remindersLists" + """) + .execute(db) +} +``` + +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: + +```swift +migrator.registerMigration("Convert 'reminders' table primary key to UUID") { db in + // Step 1: Create new table with updated schema + try #sql(""" + CREATE TABLE "new_reminders" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE, + -- all other columns from 'reminders' table + ) STRICT + """) + .execute(db) + + // Step 2: Copy data from 'reminders' to 'new_reminders' and convert integer + // IDs to UUIDs + try #sql(""" + INSERT INTO "new_reminders" + ( + "id", + "remindersListID", + -- all other columns from 'reminders' table + ) + SELECT + -- This converts integers to UUIDs, e.g. 1 -> 00000000-0000-0000-0000-000000000001 + '00000000-0000-0000-0000-' || printf('%012x', "id"), + '00000000-0000-0000-0000-' || printf('%012x', "remindersListID"), + -- all other columns from 'reminders' table + FROM "remindersLists" + """) + .execute(db) + + // Step 3 and 4 are unchanged... +} +``` ### Add primary key to all tables @@ -718,4 +806,4 @@ from CloudKit. ### Developing in the simulator TODO: talk about simulator push restrictions ---> \ No newline at end of file +--> From ee60984a7b22678500b666e97c2645a74e0c0502 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 14:08:25 -0500 Subject: [PATCH 547/581] wip --- .../Documentation.docc/Articles/CloudKit.md | 75 +++++++++++++++++-- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index e065ce34..3ecfac86 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -651,6 +651,8 @@ And in preivews you can use it like so: ## Preparing an existing schema for synchronization + + ### Convert Int primary keys to UUID @@ -747,11 +749,70 @@ migrator.registerMigration("Convert 'reminders' table primary key to UUID") { db ### Add primary key to all tables - +All tables must have a primary key to be synchronized to CloudKit, even typically you would not +add one to the table. For example, a join table that joins reminders to tags: + +```swift +@Table +struct ReminderTag { + let reminderID: Reminder.ID + let tagID: Tag.ID +} +``` + +…must be updated to have a primary key: + -## Migrating from Swift Data to SQLiteData +```diff + @Table + struct ReminderTag { ++ let id: UUID + let reminderID: Reminder.ID + let tagID: Tag.ID + } +``` + +And a migration must be run to add that column to the table. However, you must perform a multi-step +migration similar to what is described above in . +You must 1) create a new table with the new primary key column, 2) copy data from the old table +to the new table, 3) delete the old table, and finally 4) rename the new table. -## Separating schema migrations from data migrations +Here is how such a migration can look like for the `ReminderTag` table above: + +```swift +migrator.registerMigration("Add primary key to 'reminderTags' table") { db in + // Step 1: Create new table with updated schema + try #sql(""" + CREATE TABLE "new_reminderTags" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, + "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE + ) STRICT + """) + .execute(db) + + // Step 2: Copy data from 'reminderTags' to 'new_reminderTags' + try #sql(""" + INSERT INTO "new_reminderTags" + ("reminderID", "tagID") + SELECT "reminderID", "tagID" + FROM "reminderTags" + """) + .execute(db) + + // Step 3: Drop the old 'reminderTags' table + try #sql(""" + DROP TABLE "reminderTags" + """) + .execute(db) + + // Step 4: Rename 'new_reminderTags' to 'reminderTags' + try #sql(""" + ALTER TABLE "new_reminderTags" RENAME TO "reminderTags" + """) + .execute(db) +} +``` ## Tips and tricks @@ -802,8 +863,10 @@ Model.createTemporaryTrigger( This will skip the trigger's action when the row is being updated due to data being synchronized from CloudKit. - +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 +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. From f718e3db676cab14b891d46b5fdc593cf8c0d170 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 17:57:34 -0500 Subject: [PATCH 548/581] docs --- README.md | 3 ++- Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md | 4 ++-- .../Documentation.docc/Articles/ComparisonWithSwiftData.md | 1 + Sources/SQLiteData/Documentation.docc/SQLiteData.md | 5 +++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0616e2fe..24180b55 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # SQLiteData -A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL. +A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL and supporting +CloudKit synchronization. [![CI](https://github.com/pointfreeco/sqlite-data/actions/workflows/ci.yml/badge.svg)](https://github.com/pointfreeco/sqlite-data/actions/workflows/ci.yml) [![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.pointfree.co/slack-invite) diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 3ecfac86..ff012c71 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -287,8 +287,8 @@ has been added to the schema, it will populate the table with the cached records #### Adding columns > TL;DR: When adding columns to a table that has already been deployed to users' devices, you will -either need to make the column nullable, or it can be `NOT NULL` but a default value must be -provided with an `ON CONFLICT REPLACE` clause. +either need to make the column nullable, or a default value must be provided with an +`ON CONFLICT REPLACE` clause. As an example, suppose the 1.0 of your app shipped a table for a reminders list: diff --git a/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md index 8a9bb691..3f2eadfb 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -931,6 +931,7 @@ SQLiteData has only one of these limitations: * Unique constraints on columns (except for the primary key) cannot be upheld on a distributed schema. For example, if you have a `Tag` table with a unique `title` column, then what are you to do if two different devices create a tag with the title "family" at the same time? +See for more information. * Columns on freshly created tables do not need to have default values or be nullable. Only newly added columns to existing tables need to either be nullable or have a default. See for more info. diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 958a71c6..9ac42efe 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -1,10 +1,11 @@ # ``SQLiteData`` -A fast, lightweight replacement for SwiftData, powered by SQL. +A fast, lightweight replacement for SwiftData, powered by SQL and supporting CloudKit +synchronization. ## Overview -SQLiteData is a [fast](#Performance), lightweight replacement for SwiftData, including CloudKit +SQLiteData is a [fast](#Performance), lightweight replacement for SwiftData, supporting CloudKit synchronization (and even CloudKit sharing), that deploys all the way back to the iOS 13 generation of targets. From 855025a4097c41a3354f4e624a043199c1c65e18 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 5 Sep 2025 18:00:26 -0500 Subject: [PATCH 549/581] Audit reading from metadatabase (#161) * Audit metadatabase * convert more userDatabase to metadatabase * wip * wip * wip * wip * format * wip --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 8 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 65 +-- .../Documentation.docc/Articles/CloudKit.md | 18 +- .../CloudKitTests/AccountLifecycleTests.swift | 92 ++-- .../CloudKitTests/CloudKitTests.swift | 191 ++----- .../FetchRecordZoneChangesTests.swift | 248 ++++----- .../ForeignKeyConstraintTests.swift | 514 +++++++++--------- .../CloudKitTests/MetadataTests.swift | 482 +++++++++++----- .../MockCloudDatabaseTests.swift | 2 +- .../CloudKitTests/NewTableSyncTests.swift | 93 +--- .../CloudKitTests/RecordTypeTests.swift | 12 +- .../CloudKitTests/SharingTests.swift | 149 ++--- .../SyncEngineLifecycleTests.swift | 252 ++++++--- .../CloudKitTests/UserlandTests.swift | 5 +- .../Internal/AccountStatusScope.swift | 30 - .../Internal/BaseCloudKitTests.swift | 21 +- Tests/SQLiteDataTests/Internal/Schema.swift | 9 +- .../SQLiteDataTests/Internal/TestScopes.swift | 104 ++++ 18 files changed, 1261 insertions(+), 1034 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 fa347e46..3fe61b71 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) @@ -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/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 73a4e84f..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. """ ) @@ -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 @@ -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) @@ -671,7 +669,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, @@ -987,13 +985,6 @@ switch changeType { case .signIn: 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) - } - } - } case .signOut, .switchAccounts: withErrorReporting(.sqliteDataCloudKitFailure) { try deleteLocalData() @@ -1006,9 +997,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 +1127,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 +1240,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 +1261,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 +1277,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 +1324,8 @@ } } } - withErrorReporting(.sqliteDataCloudKitFailure) { - try open(table) + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await open(table) } case .permissionFailure: @@ -1411,8 +1402,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 +1513,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 +1526,10 @@ if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { if let recordDate = record.modificationDate, lastKnownDate < recordDate { - updateLastKnownServerRecord() + await updateLastKnownServerRecord() } } else { - updateLastKnownServerRecord() + await updateLastKnownServerRecord() } } diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index ff012c71..00ea8838 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/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index e5e601bd..e547a132 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() @@ -106,40 +107,35 @@ } } - @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) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test( + .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) } } } - - @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: [] ) - """ - } + ) + """ } } } 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/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 e2577b62..6c0868fb 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 @@ -9,21 +10,19 @@ 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, *) @Test func initialSync() async throws { try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -61,67 +60,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..24d6f633 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 @@ -28,18 +29,43 @@ } } - 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) + try await Task.sleep(for: .seconds(1)) - 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( @@ -113,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) @@ -155,44 +181,56 @@ 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) - - 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: [] - ) - ) - """ - } + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.read { db in - try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) - } + 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: [] + ) + ) + """ } + assertQuery(PendingRecordZoneChange.all, database: syncEngine.metadatabase) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -292,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) @@ -374,36 +413,58 @@ """ } } - } - - @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. + // * 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 { + @Test(.startImmediately(false)) func writeAndThenStart() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") Reminder(id: 1, title: "Get milk", remindersListID: 1) } } + try await Task.sleep(for: .seconds(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 +485,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/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 198145d7..659acd77 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,11 @@ 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 diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index d1bc07b9..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 61c4fc51a889a4bde3aae65a25b722153b3258b7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 20:44:45 -0500 Subject: [PATCH 550/581] docs --- Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 00ea8838..9ef8274a 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -651,9 +651,9 @@ And in preivews you can use it like so: ## Preparing an existing schema for synchronization - - - +If you have an existing app deployed to the app store using SQLite, then there may be a number +of steps you must take to prepare for adding CloudKit synchronization, mostly having to do with +primary keys. ### Convert Int primary keys to UUID From e93aeb80292fba27e8cfae27950bdfb539c7ea77 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 5 Sep 2025 19:06:31 -0700 Subject: [PATCH 551/581] CloudKit sqlite data pragmas (#162) * CloudKit: Use virtual table helpers Let's prefer our builder over SQL strings when it's easy to do so. * wip * wip * wip * wip --- .../xcshareddata/swiftpm/Package.resolved | 4 +- Examples/SyncUps/SyncUpForm.swift | 11 +- Package.resolved | 6 +- Package.swift | 2 +- Package@swift-6.0.swift | 2 +- .../CloudKit/Internal/ForeignKey.swift | 57 +--------- .../CloudKit/Internal/Pragmas.swift | 64 +++++++++++ .../CloudKit/Internal/TableInfo.swift | 38 +------ Sources/SQLiteData/CloudKit/SyncEngine.swift | 107 ++++++++++-------- .../CloudKitTests/CloudKitTests.swift | 62 +++++----- .../CloudKitTests/RecordTypeTests.swift | 76 ++++++------- .../CloudKitTests/SharingTests.swift | 4 +- .../SyncEngineValidationTests.swift | 6 +- 13 files changed, 220 insertions(+), 219 deletions(-) create mode 100644 Sources/SQLiteData/CloudKit/Internal/Pragmas.swift diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c108a80b..1f352895 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "adad5c6c5abe0c62f93c573de5be071043f621a8", - "version" : "0.17.0" + "revision" : "49f18c24145a6e061cc581662f468b7c18523b8d", + "version" : "0.18.0" } }, { diff --git a/Examples/SyncUps/SyncUpForm.swift b/Examples/SyncUps/SyncUpForm.swift index 664a2a56..1fd2b915 100644 --- a/Examples/SyncUps/SyncUpForm.swift +++ b/Examples/SyncUps/SyncUpForm.swift @@ -76,11 +76,14 @@ final class SyncUpFormModel: Identifiable { } withErrorReporting { try database.write { db in - let syncUpID = try SyncUp.upsert(syncUp).returning(\.id).fetchOne(db)! + let syncUpID = try SyncUp.upsert { syncUp }.returning(\.id).fetchOne(db)! try Attendee.where { $0.syncUpID == syncUpID }.delete().execute(db) - try Attendee - .insert(attendees.map { Attendee.Draft(name: $0.name, syncUpID: syncUpID) }) - .execute(db) + try Attendee.insert { + for attendee in attendees { + Attendee.Draft(name: attendee.name, syncUpID: syncUpID) + } + } + .execute(db) } } isDismissed = true diff --git a/Package.resolved b/Package.resolved index d2942708..47cd361e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3b49a4e324dfd736adfe38cb30f7c3a771fb77d8faee549e703df3e8b4f7f8fd", + "originHash" : "3a7b88b10aac321547bc7235de2c7480456aca2003491837d36ba7dea7636a30", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "adad5c6c5abe0c62f93c573de5be071043f621a8", - "version" : "0.17.0" + "revision" : "49f18c24145a6e061cc581662f468b7c18523b8d", + "version" : "0.18.0" } }, { diff --git a/Package.swift b/Package.swift index 9c740c67..ad14a969 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package( url: "https://github.com/pointfreeco/swift-structured-queries", - from: "0.17.0", + from: "0.18.0", traits: [ .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SQLiteDataTagged"])) ] diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index b1241a7f..f69570ea 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -27,7 +27,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.17.0"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.18.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ diff --git a/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift b/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift index 4813283f..977dc785 100644 --- a/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift +++ b/Sources/SQLiteData/CloudKit/Internal/ForeignKey.swift @@ -2,60 +2,13 @@ import Foundation import StructuredQueriesCore - package struct ForeignKey: QueryDecodable, QueryRepresentable { - typealias QueryValue = Self - + @Selection + package struct ForeignKey { let table: String let from: String let to: String - let onUpdate: Action - let onDelete: Action - let notnull: Bool - - package init(decoder: inout some QueryDecoder) throws { - guard - let table = try decoder.decode(String.self), - let from = try decoder.decode(String.self), - let to = try decoder.decode(String.self), - let onUpdate = try decoder.decode(Action.self), - let onDelete = try decoder.decode(Action.self), - let notnull = try decoder.decode(Bool.self) - else { - throw QueryDecodingError.missingRequiredColumn - } - self.table = table - self.from = from - self.to = to - self.onUpdate = onUpdate - self.onDelete = onDelete - self.notnull = notnull - } - - enum Action: String, QueryBindable { - case cascade = "CASCADE" - case restrict = "RESTRICT" - case setDefault = "SET DEFAULT" - case setNull = "SET NULL" - case noAction = "NO ACTION" - } - - static func all( - _ tableName: String - ) -> some StructuredQueriesCore.Statement { - #sql( - """ - SELECT \(columns) - FROM pragma_foreign_key_list(\(bind: tableName)) AS "foreign_keys" - JOIN pragma_table_info(\(bind: tableName)) AS "table_info" - ON "foreign_keys"."from" = "table_info"."name" - """ - ) - } - - static var columns: QueryFragment { - """ - "table", "from", "to", "on_update", "on_delete", "notnull" - """ - } + let onUpdate: ForeignKeyAction + let onDelete: ForeignKeyAction + let isNotNull: Bool } #endif diff --git a/Sources/SQLiteData/CloudKit/Internal/Pragmas.swift b/Sources/SQLiteData/CloudKit/Internal/Pragmas.swift new file mode 100644 index 00000000..d384415a --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/Pragmas.swift @@ -0,0 +1,64 @@ +@Table +struct PragmaDatabaseList { + static var tableAlias: String? { "databases" } + static var tableFragment: QueryFragment { "pragma_database_list()" } + + @Column("seq") let sequence: Int + let name: String + let file: String +} + +@Table +struct PragmaForeignKeyList { + static var tableAlias: String? { "\(Base.tableName)ForeignKeys" } + static var tableFragment: QueryFragment { + "pragma_foreign_key_list(\(quote: Base.tableName, delimiter: .text))" + } + + let id: Int + @Column("seq") let sequence: Int + let table: String + let from: String + let to: String + @Column("on_update") let onUpdate: ForeignKeyAction + @Column("on_delete") let onDelete: ForeignKeyAction + let match: String +} + +package enum ForeignKeyAction: String, QueryBindable { + case cascade = "CASCADE" + case restrict = "RESTRICT" + case setDefault = "SET DEFAULT" + case setNull = "SET NULL" + case noAction = "NO ACTION" +} + +@Table +struct PragmaIndexList { + static var tableAlias: String? { "\(Base.tableName)Indices" } + static var tableFragment: QueryFragment { + "pragma_index_list(\(quote: Base.tableName, delimiter: .text))" + } + + @Column("seq") let sequence: Int + let name: String + @Column("unique") let isUnique: Bool + let origin: String + @Column("partial") let isPartial: Bool +} + +@Table +struct PragmaTableInfo { + static var tableAlias: String? { "\(Base.tableName)TableInfo" } + static var schemaName: String? { Base.schemaName } + static var tableFragment: QueryFragment { + "pragma_table_info(\(quote: Base.tableName, delimiter: .text))" + } + + @Column("cid") let columnID: Int + let name: String + let type: String + @Column("notnull") let isNotNull: Bool + @Column("dflt_value") let defaultValue: String? + @Column("pk") let isPrimaryKey: Bool +} diff --git a/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift b/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift index 462615b8..1b416666 100644 --- a/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift +++ b/Sources/SQLiteData/CloudKit/Internal/TableInfo.swift @@ -1,44 +1,10 @@ import StructuredQueriesCore +@Selection package struct TableInfo: Codable, Hashable, QueryDecodable, QueryRepresentable { - typealias QueryValue = Self - let defaultValue: String? let isPrimaryKey: Bool let name: String - let notNull: Bool + let isNotNull: Bool let type: String - - package init(decoder: inout some QueryDecoder) throws { - self.defaultValue = try decoder.decode(String.self) - guard - let isPrimaryKey = try decoder.decode(Bool.self), - let name = try decoder.decode(String.self), - let notNull = try decoder.decode(Bool.self), - let type = try decoder.decode(String.self) - else { - throw QueryDecodingError.missingRequiredColumn - } - self.isPrimaryKey = isPrimaryKey - self.name = name - self.notNull = notNull - self.type = type - } - - static func all( - _ tableName: String - ) -> some StructuredQueriesCore.Statement { - #sql( - """ - SELECT \(columns) FROM pragma_table_info(\(bind: tableName)) - """, - as: Self.self - ) - } - - static var columns: QueryFragment { - """ - "dflt_value", "pk", "name", "notnull", "type" - """ - } } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index af81fb75..06450fc1 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -207,10 +207,25 @@ let foreignKeysByTableName = Dictionary( uniqueKeysWithValues: try userDatabase.read { db in try allTables.map { table -> (String, [ForeignKey]) in - ( - table.tableName, - try ForeignKey.all(table.tableName).fetchAll(db) - ) + func open(_: T.Type) throws -> (String, [ForeignKey]) { + ( + table.tableName, + try PragmaForeignKeyList + .join(PragmaTableInfo.all) { $0.from.eq($1.name) } + .select { + ForeignKey.Columns( + table: $0.table, + from: $0.from, + to: $0.to, + onUpdate: $0.onUpdate, + onDelete: $0.onDelete, + isNotNull: $1.isNotNull + ) + } + .fetchAll(db) + ) + } + return try open(table) } } ) @@ -257,14 +272,9 @@ try userDatabase.write { db in let attachedMetadatabasePath: String? = - try #sql( - """ - SELECT "file" - FROM pragma_database_list() - WHERE "name" = \(bind: String.sqliteDataCloudKitSchemaName) - """, - as: String.self - ) + try PragmaDatabaseList + .where { $0.name.eq(String.sqliteDataCloudKitSchemaName) } + .select(\.file) .fetchOne(db) if let attachedMetadatabasePath { let attachedMetadatabaseName = URL(filePath: metadatabase.path).lastPathComponent @@ -366,13 +376,28 @@ } .fetchAll(db) return try namesAndSchemas.compactMap { schema -> RecordType? in - guard let sql = schema.sql + guard let sql = schema.sql, let table = tablesByName[schema.name] else { return nil } - return RecordType( - tableName: schema.name, - schema: sql, - tableInfo: Set(try TableInfo.all(schema.name).fetchAll(db)) - ) + func open(_: T.Type) throws -> RecordType { + try RecordType( + tableName: schema.name, + schema: sql, + tableInfo: Set( + PragmaTableInfo + .select { + TableInfo.Columns( + defaultValue: $0.defaultValue, + isPrimaryKey: $0.isPrimaryKey, + name: $0.name, + isNotNull: $0.isNotNull, + type: $0.type + ) + } + .fetchAll(db) + ) + ) + } + return try open(table) } } let previousRecordTypeByTableName = Dictionary( @@ -1747,13 +1772,7 @@ ) } - let databasePath = try #sql( - """ - SELECT "file" FROM pragma_database_list() - """, - as: String.self - ) - .fetchOne(self) + let databasePath = try PragmaDatabaseList.select(\.file).fetchOne(self) guard let databasePath else { struct PathError: Error {} throw SyncEngine.SchemaError( @@ -1847,23 +1866,21 @@ } for table in tables { - let columnsWithUniqueConstraints = - try #sql( - """ - SELECT "name" FROM pragma_index_list(\(quote: table.tableName, delimiter: .text)) - WHERE "unique" = 1 AND "origin" <> 'pk' - """, - as: String.self - ) - .fetchAll(db) - if !columnsWithUniqueConstraints.isEmpty { - throw SyncEngine.SchemaError( - reason: .uniquenessConstraint, - debugDescription: """ + func open(_: T.Type) throws { + let columnsWithUniqueConstraints = try PragmaIndexList + .where { $0.isUnique && $0.origin != "pk" } + .select(\.name) + .fetchAll(db) + if !columnsWithUniqueConstraints.isEmpty { + throw SyncEngine.SchemaError( + reason: .uniquenessConstraint, + debugDescription: """ Uniqueness constraints are not supported for synchronized tables. """ - ) + ) + } } + try open(table) } } } @@ -1891,13 +1908,11 @@ let tableDependencies = try userDatabase.read { db in var dependencies: [HashablePrimaryKeyedTableType: [any PrimaryKeyedTable.Type]] = [:] for table in tables { - let toTables = try #sql( - """ - SELECT "table" FROM pragma_foreign_key_list(\(quote: table.tableName, delimiter: .text)) - """, - as: String.self - ) - .fetchAll(db) + func open(_: T.Type) throws -> [String] { + try PragmaForeignKeyList.select(\.table) + .fetchAll(db) + } + let toTables = try open(table) for toTable in toTables { guard let toTableType = tablesByName[toTable] else { continue } diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index e3a06d93..b2a2d4bd 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -33,14 +33,14 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", isPrimaryKey: false, name: "title", - notNull: true, + isNotNull: true, type: "TEXT" ) ] @@ -59,21 +59,21 @@ defaultValue: nil, isPrimaryKey: false, name: "coverImage", - notNull: true, + isNotNull: true, type: "BLOB" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -92,21 +92,21 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", isPrimaryKey: false, name: "position", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -130,42 +130,42 @@ defaultValue: nil, isPrimaryKey: false, name: "dueDate", - notNull: false, + isNotNull: false, type: "TEXT" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: "0", isPrimaryKey: false, name: "isCompleted", - notNull: true, + isNotNull: true, type: "INTEGER" ), [3]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "priority", - notNull: false, + isNotNull: false, type: "INTEGER" ), [4]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", - notNull: true, + isNotNull: true, type: "INTEGER" ), [5]: TableInfo( defaultValue: "\'\'", isPrimaryKey: false, name: "title", - notNull: true, + isNotNull: true, type: "TEXT" ) ] @@ -182,7 +182,7 @@ defaultValue: nil, isPrimaryKey: true, name: "title", - notNull: true, + isNotNull: true, type: "TEXT" ) ] @@ -201,21 +201,21 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "reminderID", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "tagID", - notNull: true, + isNotNull: true, type: "TEXT" ) ] @@ -232,7 +232,7 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -250,14 +250,14 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", - notNull: false, + isNotNull: false, type: "INTEGER" ) ] @@ -276,14 +276,14 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", isPrimaryKey: false, name: "parentID", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -302,14 +302,14 @@ defaultValue: "0", isPrimaryKey: false, name: "count", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -328,21 +328,21 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", isPrimaryKey: false, name: "isOn", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "modelAID", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -361,21 +361,21 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "modelBID", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: "\'\'", isPrimaryKey: false, name: "title", - notNull: true, + isNotNull: true, type: "TEXT" ) ] diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 6853cc5c..af2e86ca 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -31,14 +31,14 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: "\'\'", isPrimaryKey: false, name: "title", - notNull: true, + isNotNull: true, type: "TEXT" ) ] @@ -57,21 +57,21 @@ defaultValue: nil, isPrimaryKey: false, name: "coverImage", - notNull: true, + isNotNull: true, type: "BLOB" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -90,21 +90,21 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", isPrimaryKey: false, name: "position", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -128,42 +128,42 @@ defaultValue: nil, isPrimaryKey: false, name: "dueDate", - notNull: false, + isNotNull: false, type: "TEXT" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: "0", isPrimaryKey: false, name: "isCompleted", - notNull: true, + isNotNull: true, type: "INTEGER" ), [3]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "priority", - notNull: false, + isNotNull: false, type: "INTEGER" ), [4]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", - notNull: true, + isNotNull: true, type: "INTEGER" ), [5]: TableInfo( defaultValue: "\'\'", isPrimaryKey: false, name: "title", - notNull: true, + isNotNull: true, type: "TEXT" ) ] @@ -180,7 +180,7 @@ defaultValue: nil, isPrimaryKey: true, name: "title", - notNull: true, + isNotNull: true, type: "TEXT" ) ] @@ -199,21 +199,21 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "reminderID", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "tagID", - notNull: true, + isNotNull: true, type: "TEXT" ) ] @@ -230,7 +230,7 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -248,14 +248,14 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "parentID", - notNull: false, + isNotNull: false, type: "INTEGER" ) ] @@ -274,14 +274,14 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", isPrimaryKey: false, name: "parentID", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -300,14 +300,14 @@ defaultValue: "0", isPrimaryKey: false, name: "count", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -326,21 +326,21 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: "0", isPrimaryKey: false, name: "isOn", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "modelAID", - notNull: true, + isNotNull: true, type: "INTEGER" ) ] @@ -359,21 +359,21 @@ defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "modelBID", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: "\'\'", isPrimaryKey: false, name: "title", - notNull: true, + isNotNull: true, type: "TEXT" ) ] @@ -459,49 +459,49 @@ defaultValue: nil, isPrimaryKey: false, name: "dueDate", - notNull: false, + isNotNull: false, type: "TEXT" ), [1]: TableInfo( defaultValue: nil, isPrimaryKey: true, name: "id", - notNull: true, + isNotNull: true, type: "INTEGER" ), [2]: TableInfo( defaultValue: "0", isPrimaryKey: false, name: "isCompleted", - notNull: true, + isNotNull: true, type: "INTEGER" ), [3]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "newFeature", - notNull: true, + isNotNull: true, type: "INTEGER" ), [4]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "priority", - notNull: false, + isNotNull: false, type: "INTEGER" ), [5]: TableInfo( defaultValue: nil, isPrimaryKey: false, name: "remindersListID", - notNull: true, + isNotNull: true, type: "INTEGER" ), [6]: TableInfo( defaultValue: "\'\'", isPrimaryKey: false, name: "title", - notNull: true, + isNotNull: true, type: "TEXT" ) ] diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index d6d63194..ed2856e5 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -46,7 +46,7 @@ to: "id", onUpdate: .cascade, onDelete: .cascade, - notnull: true + isNotNull: true ) ] ), @@ -113,7 +113,7 @@ to: "id", onUpdate: .noAction, onDelete: .cascade, - notnull: true + isNotNull: true ) ] ), diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift index c0734ebb..5b24fe01 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineValidationTests.swift @@ -96,7 +96,7 @@ to: "id", onUpdate: .noAction, onDelete: .noAction, - notnull: false + isNotNull: false ) ), debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# @@ -155,7 +155,7 @@ to: "id", onUpdate: .noAction, onDelete: .restrict, - notnull: false + isNotNull: false ) ), debugDescription: #"Foreign key "childs"."parentID" action not supported. Must be 'CASCADE', 'SET DEFAULT' or 'SET NULL'."# @@ -221,7 +221,7 @@ to: "id", onUpdate: .noAction, onDelete: .cascade, - notnull: false + isNotNull: false ) ), debugDescription: #"Foreign key "childs"."parentID" references table "parents" that is not synchronized. Update 'SyncEngine.init' to synchronize "parents". "# From 1a173d8823fd2ba49d088b3a2795ed9a68bb9e7a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 6 Sep 2025 14:28:03 -0500 Subject: [PATCH 552/581] Move menu into toolbar hstack. --- Examples/Reminders/RemindersDetail.swift | 46 ++++++++++++------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index c84dc4e8..afc42e10 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -245,33 +245,33 @@ struct RemindersDetailView: View { Image(systemName: "square.and.arrow.up") } } - } - Menu { - Group { - Menu { - ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in - Button { - Task { await model.orderingButtonTapped(ordering) } - } label: { - Text(ordering.rawValue) - ordering.icon + Menu { + Group { + Menu { + ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in + Button { + Task { await model.orderingButtonTapped(ordering) } + } label: { + Text(ordering.rawValue) + ordering.icon + } } + } label: { + Text("Sort By") + Text(model.ordering.rawValue) + Image(systemName: "arrow.up.arrow.down") + } + Button { + Task { await model.showCompletedButtonTapped() } + } label: { + Text(model.showCompleted ? "Hide Completed" : "Show Completed") + Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") } - } label: { - Text("Sort By") - Text(model.ordering.rawValue) - Image(systemName: "arrow.up.arrow.down") - } - Button { - Task { await model.showCompletedButtonTapped() } - } label: { - Text(model.showCompleted ? "Hide Completed" : "Show Completed") - Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") } + .tint(model.detailType.color) + } label: { + Image(systemName: "ellipsis.circle") } - .tint(model.detailType.color) - } label: { - Image(systemName: "ellipsis.circle") } } } From 56d34fbf1fe2d4dd50645e87d3895ea6abb471c2 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:28:32 -0500 Subject: [PATCH 553/581] Fix tests for iOS. (#165) --- .../CloudKit/Internal/CloudKitFunctions.swift | 3 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 8 ++--- .../SQLiteData/Internal/UserDatabase.swift | 8 ++--- Tests/SQLiteDataTests/AssertQueryTests.swift | 11 +++++++ .../CloudKitTests/AccountLifecycleTests.swift | 2 +- .../CloudKitTests/CloudKitTests.swift | 4 +-- .../CloudKitTests/SchemaChangeTests.swift | 10 +++---- .../SharingPermissionsTests.swift | 4 +-- .../SQLiteDataTests/CustomFunctionTests.swift | 1 + .../Internal/UserDatabaseHelpers.swift | 29 ++----------------- 10 files changed, 31 insertions(+), 49 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift b/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift index 667987cc..fb1fa47f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift +++ b/Sources/SQLiteData/CloudKit/Internal/CloudKitFunctions.swift @@ -18,9 +18,8 @@ || share.currentUserParticipant?.permission == .readWrite } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @DatabaseFunction("sqlitedata_icloud_syncEngineIsSynchronizingChanges") func syncEngineIsSynchronizingChanges() -> Bool { - SyncEngine._isSynchronizingChanges + _isSynchronizingChanges } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 06450fc1..fbb1c8df 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -251,8 +251,6 @@ try validateSchema() } - @TaskLocal package static var _isSynchronizingChanges = false - nonisolated package func setUpSyncEngine() throws { let migrator = metadatabaseMigrator() #if DEBUG @@ -464,7 +462,7 @@ previousRecordTypeByTableName[tableName] == nil } - try Self.$_isSynchronizingChanges.withValue(false) { + try $_isSynchronizingChanges.withValue(false) { for tableName in newTableNames { try self.uploadRecordsToCloudKit(tableName: tableName, db: db) } @@ -1304,7 +1302,7 @@ else { continue } func open(_: T.Type) async throws { try await userDatabase.write { db in - try Self.$_isSynchronizingChanges.withValue(false) { + try $_isSynchronizingChanges.withValue(false) { switch foreignKey.onDelete { case .cascade: try T @@ -2006,4 +2004,6 @@ } return query } + + @TaskLocal package var _isSynchronizingChanges = false #endif diff --git a/Sources/SQLiteData/Internal/UserDatabase.swift b/Sources/SQLiteData/Internal/UserDatabase.swift index 96ca2a2a..520e8601 100644 --- a/Sources/SQLiteData/Internal/UserDatabase.swift +++ b/Sources/SQLiteData/Internal/UserDatabase.swift @@ -15,18 +15,16 @@ package struct UserDatabase { database.configuration } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package func write( _ updates: @Sendable (Database) throws -> T ) async throws -> T { try await database.write { db in - try SyncEngine.$_isSynchronizingChanges.withValue(true) { + try $_isSynchronizingChanges.withValue(true) { try updates(db) } } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package func read( _ updates: @Sendable (Database) throws -> T ) async throws -> T { @@ -36,19 +34,17 @@ package struct UserDatabase { } @_disfavoredOverload - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package func write( _ updates: (Database) throws -> T ) throws -> T { try database.write { db in - try SyncEngine.$_isSynchronizingChanges.withValue(true) { + try $_isSynchronizingChanges.withValue(true) { try updates(db) } } } @_disfavoredOverload - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package func read( _ updates: (Database) throws -> T ) throws -> T { diff --git a/Tests/SQLiteDataTests/AssertQueryTests.swift b/Tests/SQLiteDataTests/AssertQueryTests.swift index 17179471..4878a923 100644 --- a/Tests/SQLiteDataTests/AssertQueryTests.swift +++ b/Tests/SQLiteDataTests/AssertQueryTests.swift @@ -10,6 +10,7 @@ import Testing .snapshots(record: .failed), ) struct AssertQueryTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryBasic() throws { assertQuery( Record.all.select(\.id) @@ -23,6 +24,8 @@ struct AssertQueryTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryRecord() throws { assertQuery( Record.where { $0.id == 1 } @@ -37,6 +40,8 @@ struct AssertQueryTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryBasicUpdate() throws { assertQuery( Record.all @@ -52,6 +57,8 @@ struct AssertQueryTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryRecordUpdate() throws { assertQuery( Record @@ -69,7 +76,9 @@ struct AssertQueryTests { """ } } + #if DEBUG + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryBasicIncludeSQL() throws { assertQuery( includeSQL: true, @@ -90,7 +99,9 @@ struct AssertQueryTests { } } #endif + #if DEBUG + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assertQueryRecordIncludeSQL() throws { assertQuery( includeSQL: true, diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index e547a132..ba1e4bdc 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -25,7 +25,7 @@ await signOut() - try await userDatabase.userRead { db in + try await userDatabase.read { db in try #expect(RemindersList.count().fetchOne(db) == 0) try #expect(Reminder.count().fetchOne(db) == 0) try #expect(RemindersListPrivate.count().fetchOne(db) == 0) diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index b2a2d4bd..8fec608f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -442,7 +442,7 @@ as: String.self ) assertInlineSnapshot( - of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), + of: try { try userDatabase.write { try query.fetchAll($0) } }(), as: .customDump ) { """ @@ -458,7 +458,7 @@ try syncEngine.tearDownSyncEngine() assertInlineSnapshot( - of: try { try userDatabase.userRead { try query.fetchAll($0) } }(), + of: try { try userDatabase.read { try query.fetchAll($0) } }(), as: .customDump ) { """ diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index ebd7d64f..9292a102 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -81,10 +81,10 @@ ) defer { _ = relaunchedSyncEngine } - let remindersLists = try await userDatabase.userRead { db in + let remindersLists = try await userDatabase.read { db in try RemindersListWithPosition.order(by: \.id).fetchAll(db) } - let reminders = try await userDatabase.userRead { db in + let reminders = try await userDatabase.read { db in try ReminderWithPosition.order(by: \.id).fetchAll(db) } @@ -154,7 +154,7 @@ ) defer { _ = relaunchedSyncEngine } - let remindersLists = try await userDatabase.userRead { db in + let remindersLists = try await userDatabase.read { db in try RemindersListWithData.order(by: \.id).fetchAll(db) } @@ -223,7 +223,7 @@ ) defer { _ = relaunchedSyncEngine } - let remindersLists = try await userDatabase.userRead { db in + let remindersLists = try await userDatabase.read { db in try RemindersListWithData.order(by: \.id).fetchAll(db) } @@ -280,7 +280,7 @@ ) defer { _ = relaunchedSyncEngine } - let images = try await userDatabase.userRead { db in + let images = try await userDatabase.read { db in try Image.order(by: \.id).fetchAll(db) } diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift index 4457e490..aa3e3957 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift @@ -346,7 +346,7 @@ } try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - try await self.userDatabase.userRead { db in + try await self.userDatabase.read { db in try #expect(Reminder.all.fetchCount(db) == 0) } assertInlineSnapshot(of: container, as: .customDump) { @@ -432,7 +432,7 @@ } try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - try await self.userDatabase.userRead { db in + try await self.userDatabase.read { db in try #expect(RemindersList.find(1).fetchOne(db) == RemindersList(id: 1, title: "Personal")) } assertInlineSnapshot(of: container, as: .customDump) { diff --git a/Tests/SQLiteDataTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift index 60eda159..3c9436e8 100644 --- a/Tests/SQLiteDataTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -7,6 +7,7 @@ import Testing Date(timeIntervalSinceReferenceDate: 0) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func basics() throws { var configuration = Configuration() configuration.prepareDatabase { db in diff --git a/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift b/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift index 74c93a9d..710c6b07 100644 --- a/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/UserDatabaseHelpers.swift @@ -2,47 +2,22 @@ import GRDB import SQLiteData extension UserDatabase { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func userWrite( _ updates: @Sendable (Database) throws -> T ) async throws -> T { try await write { db in - try SyncEngine.$_isSynchronizingChanges.withValue(false) { - try updates(db) - } - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func userRead( - _ updates: @Sendable (Database) throws -> T - ) async throws -> T { - try await read { db in - try SyncEngine.$_isSynchronizingChanges.withValue(false) { + try $_isSynchronizingChanges.withValue(false) { try updates(db) } } } @_disfavoredOverload - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func userWrite( _ updates: (Database) throws -> T ) throws -> T { try write { db in - try SyncEngine.$_isSynchronizingChanges.withValue(false) { - try updates(db) - } - } - } - - @_disfavoredOverload - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func userRead( - _ updates: (Database) throws -> T - ) throws -> T { - try write { db in - try SyncEngine.$_isSynchronizingChanges.withValue(false) { + try $_isSynchronizingChanges.withValue(false) { try updates(db) } } From aa23b59e87df6f7b88bb4e7916d9f3bbc936beb6 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 8 Sep 2025 11:29:37 -0700 Subject: [PATCH 554/581] Optimize a few sync metadata queries (#169) Let's select/decode just the data we need. --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 9 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 91 ++++++++++--------- 2 files changed, 52 insertions(+), 48 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 3fe61b71..eac7c4a3 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -110,9 +110,10 @@ try await metadatabase.read { db in try SyncMetadata .where { $0.recordName.eq(recordName) } + .select { ($0.recordType, $0.recordName, $0.lastKnownServerRecord) } .fetchOne(db) } ?? nil - guard let metadata + guard let (recordType, recordName, lastKnownServerRecord) = metadata else { throw SharingError( recordTableName: T.tableName, @@ -125,10 +126,10 @@ } let rootRecord = - metadata.lastKnownServerRecord + lastKnownServerRecord ?? CKRecord( - recordType: metadata.recordType, - recordID: CKRecord.ID(recordName: metadata.recordName, zoneID: defaultZone.zoneID) + recordType: recordType, + recordID: CKRecord.ID(recordName: recordName, zoneID: defaultZone.zoneID) ) var existingShare: CKShare? { diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index fbb1c8df..60d88507 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -923,53 +923,56 @@ } let deletedRecordNames = deletedRecordIDs.map(\.recordName) - let (metadataOfDeletions, recordsWithRoot): ([SyncMetadata], [RecordWithRoot]) = - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await metadatabase.read { db in - let metadataOfDeletions = try SyncMetadata.where { - $0.recordName.in(deletedRecordNames) - } - .fetchAll(db) + let (sharesToDelete, recordsWithRoot): + ([CKShare?], [(lastKnownServerRecord: CKRecord?, rootLastKnownServerRecord: CKRecord?)]) = + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await metadatabase.read { db in + let sharesToDelete = + try SyncMetadata + .where { $0.isShared && $0.recordName.in(deletedRecordNames) } + .select(\.share) + .fetchAll(db) - let recordsWithRoot = - try With { - SyncMetadata - .where { $0.parentRecordName.is(nil) && $0.recordName.in(deletedRecordNames) } - .select { - RecordWithRoot.Columns( - parentRecordName: $0.parentRecordName, - recordName: $0.recordName, - lastKnownServerRecord: $0.lastKnownServerRecord, - rootRecordName: $0.recordName, - rootLastKnownServerRecord: $0.lastKnownServerRecord + let recordsWithRoot = + try With { + SyncMetadata + .where { $0.parentRecordName.is(nil) && $0.recordName.in(deletedRecordNames) } + .select { + RecordWithRoot.Columns( + parentRecordName: $0.parentRecordName, + recordName: $0.recordName, + lastKnownServerRecord: $0.lastKnownServerRecord, + rootRecordName: $0.recordName, + rootLastKnownServerRecord: $0.lastKnownServerRecord + ) + } + .union( + all: true, + SyncMetadata + .join(RecordWithRoot.all) { $1.recordName.is($0.parentRecordName) } + .select { metadata, tree in + RecordWithRoot.Columns( + parentRecordName: metadata.parentRecordName, + recordName: metadata.recordName, + lastKnownServerRecord: metadata.lastKnownServerRecord, + rootRecordName: tree.rootRecordName, + rootLastKnownServerRecord: tree.lastKnownServerRecord + ) + } ) - } - .union( - all: true, - SyncMetadata - .join(RecordWithRoot.all) { $1.recordName.is($0.parentRecordName) } - .select { metadata, tree in - RecordWithRoot.Columns( - parentRecordName: metadata.parentRecordName, - recordName: metadata.recordName, - lastKnownServerRecord: metadata.lastKnownServerRecord, - rootRecordName: tree.rootRecordName, - rootLastKnownServerRecord: tree.lastKnownServerRecord - ) - } - ) - } query: { - RecordWithRoot - .where { $0.recordName.in(deletedRecordNames) } - } - .fetchAll(db) + } query: { + RecordWithRoot + .where { $0.recordName.in(deletedRecordNames) } + .select { ($0.lastKnownServerRecord, $0.rootLastKnownServerRecord) } + } + .fetchAll(db) - return (metadataOfDeletions, recordsWithRoot) + return (sharesToDelete, recordsWithRoot) + } } - } - ?? ([], []) + ?? ([], []) - let shareRecordIDsToDelete = metadataOfDeletions.compactMap(\.share?.recordID) + let shareRecordIDsToDelete = sharesToDelete.compactMap(\.?.recordID) for recordWithRoot in recordsWithRoot { guard @@ -1873,8 +1876,8 @@ throw SyncEngine.SchemaError( reason: .uniquenessConstraint, debugDescription: """ - Uniqueness constraints are not supported for synchronized tables. - """ + Uniqueness constraints are not supported for synchronized tables. + """ ) } } From ef8acf449b66442c1e319734ffe63247b43ab1b0 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:29:57 -0500 Subject: [PATCH 555/581] More efficient joins to SyncMetadata table (#163) * Improve tools for joining SyncMetadata table. * wip * wip * wip * wip * wip * wip --------- Co-authored-by: Stephen Celis --- .github/workflows/ci.yml | 2 + Examples/Reminders/RemindersLists.swift | 6 +- .../CloudKit/Internal/Metadatabase.swift | 14 ++-- .../SQLiteData/CloudKit/SyncMetadata.swift | 13 +++- .../Documentation.docc/Articles/CloudKit.md | 6 +- .../Documentation.docc/SQLiteData.md | 1 + .../CloudKitTests/MetadataTests.swift | 76 +++++++++++++++++++ .../UnattachedSyncEngineTests.swift | 0 8 files changed, 102 insertions(+), 16 deletions(-) rename Tests/{SharingGRDBTests => SQLiteDataTests}/CloudKitTests/UnattachedSyncEngineTests.swift (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2d594e5..c3bb21ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,8 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: List devices available + run: xcrun simctl list --json devices available 'iPhone' - name: xcodebuild ${{ matrix.scheme }} run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="${{ matrix.scheme }}" xcodebuild-raw - name: Output test failures diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 71303b51..56865fc2 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -12,10 +12,8 @@ class RemindersListsModel { RemindersList .group(by: \.id) .order(by: \.position) - .leftJoin(Reminder.all) { - $0.id.eq($1.remindersListID) && !$1.isCompleted - } - .leftJoin(SyncMetadata.all) { $0.recordName.eq($2.recordName) } + .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted } + .leftJoin(SyncMetadata.all) { $0.hasMetadata(in: $2) } .select { ReminderListState.Columns( remindersCount: $1.id.count(), diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index e1912ad7..2df3bb08 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -51,7 +51,7 @@ migrator.registerMigration("Create Metadata Tables") { db in try #sql( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_metadata" ( "recordPrimaryKey" TEXT NOT NULL, "recordType" TEXT NOT NULL, "recordName" TEXT NOT NULL AS ("recordPrimaryKey" || ':' || "recordType"), @@ -73,21 +73,21 @@ .execute(db) try #sql( """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" + CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_parentRecordName" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("parentRecordName") """ ) .execute(db) try #sql( """ - CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" + CREATE INDEX "\(raw: .sqliteDataCloudKitSchemaName)_metadata_isShared" ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("isShared") """ ) .execute(db) try #sql( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( "tableName" TEXT NOT NULL PRIMARY KEY, "schema" TEXT NOT NULL, "tableInfo" TEXT NOT NULL @@ -97,7 +97,7 @@ .execute(db) try #sql( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_stateSerialization" ( "scope" TEXT NOT NULL PRIMARY KEY, "data" TEXT NOT NULL ) STRICT @@ -106,7 +106,7 @@ .execute(db) try #sql( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_unsyncedRecordIDs" ( "recordName" TEXT NOT NULL, "zoneName" TEXT NOT NULL, "ownerName" TEXT NOT NULL, @@ -117,7 +117,7 @@ .execute(db) try #sql( """ - CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( + CREATE TABLE "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( "pendingRecordZoneChange" BLOB NOT NULL ) STRICT """ diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 316aa9fc..ce79f912 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -163,8 +163,17 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PrimaryKeyedTableDefinition where PrimaryKey.QueryOutput: IdentifierStringConvertible { - public var recordName: some QueryExpression { - _recordName + /// A query expression for whether or not this row has associated sync metadata. + /// + /// This helper can be useful when joining your tables to the ``SyncMetadata`` table: + /// + /// ```swift + /// RemindersList + /// .leftJoin(SyncMetadata.all) { $0.hasMetadata.in($1) } + /// ``` + public func hasMetadata(in metadata: SyncMetadata.TableColumns) -> some QueryExpression { + metadata.recordType.eq(QueryValue.tableName) + && #sql("\(primaryKey)").eq(metadata.recordPrimaryKey) } } diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 9ef8274a..e9f1ead0 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -571,7 +571,7 @@ following: @FetchAll( RemindersList - .leftJoin(SyncMetadata.all) { $0.recordName.eq($1.recordName) } + .leftJoin(SyncMetadata.all) { $0.hasMetadata(in: $1) } .select { Row.Columns( remindersList: $0, @@ -582,8 +582,8 @@ following: var rows ``` -Here we have used the ``StructuredQueriesCore/PrimaryKeyedTableDefinition/recordName`` helper that -is defined on all primary key tables so that we can join ``SyncMetadata`` to `RemindersList`. +Here we have used the ``StructuredQueriesCore/PrimaryKeyedTableDefinition/hasMetadata(in:)`` helper +that is defined on all primary key tables so that we can join ``SyncMetadata`` to `RemindersList`.