From 7e872b6321121fc80dfd1a6b3a12037b018420c0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 13 Aug 2025 16:03:29 -0500 Subject: [PATCH 01/14] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 46 +++++++++++-------- .../SharingGRDBCore/CloudKit/Triggers.swift | 16 ++++--- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4ae48eab..4c278d60 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -438,7 +438,7 @@ try await setUpSyncEngine() } - func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { + func didUpdate(recordName: String, zoneID: CKRecordZone.ID?, share _: CKShare?) { let zoneID = zoneID ?? defaultZone.zoneID let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -455,21 +455,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.insert(.deleteRecord(share.recordID), at: 0) + } + syncEngine?.state.add(pendingRecordZoneChanges: changes) } // TODO: Possible to get test coverage on this? @@ -1416,17 +1418,18 @@ @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, share in syncEngine.didUpdate( recordName: recordName, - zoneID: zoneID + zoneID: zoneID, + share: share ) } } 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) } } @@ -1454,9 +1457,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 +1470,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 } } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index e2dacc02..df95b6c8 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -112,7 +112,8 @@ extension SyncMetadata { Values(.didDelete( recordName: old.recordName, lastKnownServerRecord: old.lastKnownServerRecord - ?? rootServerRecord(recordName: old.recordName) + ?? rootServerRecord(recordName: old.recordName), + share: old.share )) } when: { _ in !SyncEngine.isSynchronizingChanges() @@ -131,24 +132,27 @@ extension QueryExpression where Self == SQLQueryExpression<()> { .didUpdate( recordName: new.recordName, lastKnownServerRecord: new.lastKnownServerRecord - ?? rootServerRecord(recordName: new.recordName) + ?? rootServerRecord(recordName: new.recordName), + share: new.share ) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private static func didUpdate( recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression + lastKnownServerRecord: some QueryExpression, + share: some QueryExpression ) -> Self { - Self("\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(recordName), \(lastKnownServerRecord))") + Self("\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(recordName), \(lastKnownServerRecord), \(share))") } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) fileprivate static func didDelete( recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression + lastKnownServerRecord: some QueryExpression, + share: some QueryExpression ) -> Self { - Self("\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord))") + Self("\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord), \(share))") } } From 7f16163642c79017591c75710453860155efcbd1 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 13 Aug 2025 16:04:58 -0500 Subject: [PATCH 02/14] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 4c278d60..617fc95b 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -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] = [] @@ -460,16 +462,20 @@ let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - var changes: [CKSyncEngine.PendingRecordZoneChange] = [ - .deleteRecord( - CKRecord.ID( - recordName: recordName, - zoneID: zoneID + var changes: [CKSyncEngine.PendingRecordZoneChange] = [] + + if let share { + changes.append(.deleteRecord(share.recordID)) + } + if zoneID.ownerName == CKCurrentUserDefaultName { + changes.append( + .deleteRecord( + CKRecord.ID( + recordName: recordName, + zoneID: zoneID + ) ) ) - ] - if let share { - changes.insert(.deleteRecord(share.recordID), at: 0) } syncEngine?.state.add(pendingRecordZoneChanges: changes) } @@ -1503,7 +1509,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") @@ -1571,7 +1578,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 { From afde55f71d9dc6576bc71057e8ea96efbfcf69ff Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 13 Aug 2025 18:06:24 -0500 Subject: [PATCH 03/14] wip --- .../CloudKit/Metadatabase.swift | 1 + .../SharingGRDBCore/CloudKit/SyncEngine.swift | 61 ++++- .../SyncMetadata+MacroExpansion.swift | 16 +- .../CloudKit/SyncMetadata.swift | 2 + .../SharingGRDBCore/CloudKit/Triggers.swift | 43 ++- .../CloudKitTests/NewTableSyncTests.swift | 2 + .../CloudKitTests/SharingTests.swift | 1 + .../CloudKitTests/TriggerTests.swift | 244 +++++++++++++----- 8 files changed, 283 insertions(+), 87 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index cb8cad7d..01a3c43c 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 617fc95b..1d835806 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -440,7 +440,11 @@ try await setUpSyncEngine() } - func didUpdate(recordName: String, zoneID: CKRecordZone.ID?, share _: CKShare?) { + func didUpdate( + recordName: String, + zoneID: CKRecordZone.ID?, + share _: CKShare? + ) { let zoneID = zoneID ?? defaultZone.zoneID let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -457,17 +461,32 @@ ) } - func didDelete(recordName: String, zoneID: CKRecordZone.ID?, share: CKShare?) { + 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] = [] - if let share { + let isOwner = zoneID.ownerName == CKCurrentUserDefaultName + switch (share, isOwner) { + case (let share?, true): changes.append(.deleteRecord(share.recordID)) - } - if zoneID.ownerName == CKCurrentUserDefaultName { + changes.append( + .deleteRecord( + CKRecord.ID( + recordName: recordName, + zoneID: zoneID + ) + ) + ) + case (let share?, false): + changes.append(.deleteRecord(share.recordID)) + case (.none, _): changes.append( .deleteRecord( CKRecord.ID( @@ -671,6 +690,31 @@ } #endif + let deletedRecordNames: [String] = changes.compactMap { + switch $0 { + case .saveRecord(_): + return nil + case .deleteRecord(let recordID): + return recordID.recordName + @unknown default: + return nil + } + } + + // let shares = get all shares + // let deletes = get all deletes + // CTE MAGIC HERE: get all root records that are a parent to the deletes and whose share is contained in the shares + // remove those deletions from + + await withErrorReporting { + try await userDatabase.write { db in + try SyncMetadata + .where { $0.recordName.in(deletedRecordNames) } + .delete() + .execute(db) + } + } + let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in var missingTable: CKRecord.ID? var missingRecord: CKRecord.ID? @@ -1435,7 +1479,12 @@ fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { return Self("didDelete") { recordName, zoneID, share in - syncEngine.didDelete(recordName: recordName, zoneID: zoneID, share: share) + syncEngine + .didDelete( + recordName: recordName, + zoneID: zoneID, + share: share + ) } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index d6928a25..58400280 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,7 @@ 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 +74,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 +105,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 +128,9 @@ guard let isShared else { throw QueryDecodingError.missingRequiredColumn } + guard let isDeleted else { + throw QueryDecodingError.missingRequiredColumn + } guard let userModificationDate else { throw QueryDecodingError.missingRequiredColumn } @@ -130,6 +141,7 @@ self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields self.share = share self.isShared = isShared + self.isDeleted = isDeleted self.userModificationDate = userModificationDate } } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index 85676907..ae998bab 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -60,6 +60,8 @@ // @Column(as: CKShare?.SystemFieldsRepresentation.self) public var share: CKShare? + public var isDeleted = false + // @Column(generated: .virtual) public let isShared: Bool diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index df95b6c8..89aa7cc7 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -8,7 +8,8 @@ extension PrimaryKeyedTable { [ afterInsert(parentForeignKey: parentForeignKey), afterUpdate(parentForeignKey: parentForeignKey), - afterDelete, + afterDeleteFromUser, + afterDeleteFromSyncEngine, ] } @@ -28,17 +29,36 @@ extension PrimaryKeyedTable { ) } - fileprivate static var afterDelete: TemporaryTrigger { + fileprivate static var afterDeleteFromUser: TemporaryTrigger { createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)", + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_user", ifNotExists: true, after: .delete { old in SyncMetadata .where { $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) - && $0.recordType.eq(tableName) + && $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() } ) } @@ -108,15 +128,15 @@ extension SyncMetadata { fileprivate static let afterDeleteTrigger = createTemporaryTrigger( "after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, - after: .delete { old in + after: .update(of: \.isDeleted) { _, new in Values(.didDelete( - recordName: old.recordName, - lastKnownServerRecord: old.lastKnownServerRecord - ?? rootServerRecord(recordName: old.recordName), - share: old.share + recordName: new.recordName, + lastKnownServerRecord: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName), + share: new.share )) - } when: { _ in - !SyncEngine.isSynchronizingChanges() + } when: { old, new in + !old.isDeleted && new.isDeleted //&& !SyncEngine.isSynchronizingChanges() } ) } @@ -131,6 +151,7 @@ extension QueryExpression where Self == SQLQueryExpression<()> { ) -> Self { .didUpdate( recordName: new.recordName, + // TODO: separate lastKnownServerRecord from rootRecord lastKnownServerRecord: new.lastKnownServerRecord ?? rootServerRecord(recordName: new.recordName), share: new.share diff --git a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift index 6da480a7..b480d3d6 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..a7981c55 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..ba6173b2 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") 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,7 +54,7 @@ extension BaseCloudKitTests { SELECT "ancestorMetadatas"."lastKnownServerRecord" FROM "ancestorMetadatas" WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - ))); + )), "new"."share"); END """, [2]: """ @@ -74,106 +74,214 @@ 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 @@ -184,7 +292,7 @@ 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 @@ -195,7 +303,7 @@ 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 @@ -206,7 +314,7 @@ 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 @@ -217,7 +325,7 @@ 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 @@ -228,7 +336,7 @@ 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 @@ -239,7 +347,7 @@ 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 @@ -250,7 +358,7 @@ 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 @@ -261,7 +369,7 @@ 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 @@ -272,7 +380,7 @@ 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 @@ -283,7 +391,7 @@ 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 @@ -294,7 +402,7 @@ 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 @@ -305,7 +413,7 @@ 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 @@ -316,7 +424,7 @@ 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 @@ -327,7 +435,7 @@ 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 @@ -338,7 +446,7 @@ 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 @@ -349,7 +457,7 @@ 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 @@ -360,7 +468,7 @@ 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 @@ -371,7 +479,7 @@ 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 @@ -382,7 +490,7 @@ 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 @@ -393,7 +501,7 @@ 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 @@ -404,7 +512,7 @@ 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 @@ -415,7 +523,7 @@ 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 @@ -426,7 +534,7 @@ 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 From 4ef9497bcf0d1f91cdcb210fa6ee76779807fb69 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 11:38:05 -0500 Subject: [PATCH 04/14] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 236 ++++++++++++------ .../SyncMetadata+MacroExpansion.swift | 73 +++++- .../CloudKit/SyncMetadata.swift | 28 ++- .../SharingGRDBCore/CloudKit/Triggers.swift | 2 +- .../ForeignKeyConstraintTests.swift | 21 ++ 5 files changed, 270 insertions(+), 90 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 1d835806..509489a6 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -552,76 +552,176 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { - 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) +extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { + 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 { - case .accountChange(let changeType): - await handleAccountChange(changeType: changeType, syncEngine: syncEngine) - case .stateUpdate(let stateSerialization): - handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) - case .fetchedDatabaseChanges(let modifications, let deletions): - await handleFetchedDatabaseChanges( - modifications: modifications, - deletions: deletions, - syncEngine: syncEngine - ) - case .sentDatabaseChanges: - break - case .fetchedRecordZoneChanges(let modifications, let deletions): - await handleFetchedRecordZoneChanges( - modifications: modifications, - deletions: deletions, - syncEngine: syncEngine - ) - case .sentRecordZoneChanges( - let savedRecords, - let failedRecordSaves, - let deletedRecordIDs, - let failedRecordDeletes - ): - await handleSentRecordZoneChanges( - savedRecords: savedRecords, - failedRecordSaves: failedRecordSaves, - deletedRecordIDs: deletedRecordIDs, - failedRecordDeletes: failedRecordDeletes, - syncEngine: syncEngine - ) - case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, + package func handleEvent(_ event: Event, syncEngine: any SyncEngineProtocol) async { + logger.log(event, syncEngine: syncEngine) + + switch event { + case .accountChange(let changeType): + await handleAccountChange(changeType: changeType, syncEngine: syncEngine) + case .stateUpdate(let stateSerialization): + handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) + case .fetchedDatabaseChanges(let modifications, let deletions): + await handleFetchedDatabaseChanges( + modifications: modifications, + deletions: deletions, + syncEngine: syncEngine + ) + case .sentDatabaseChanges: + break + case .fetchedRecordZoneChanges(let modifications, let deletions): + await handleFetchedRecordZoneChanges( + modifications: modifications, + deletions: deletions, + syncEngine: syncEngine + ) + case .sentRecordZoneChanges( + let savedRecords, + let failedRecordSaves, + let deletedRecordIDs, + let failedRecordDeletes + ): + await handleSentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes, + syncEngine: syncEngine + ) + case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, .didFetchChanges, .willSendChanges, .didSendChanges: - break + 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 + ) + } + + private func pendingRecordZoneChanges( + options: CKSyncEngine.SendChangesOptions, + syncEngine: any SyncEngineProtocol + ) async -> [CKSyncEngine.PendingRecordZoneChange] { + var changes = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) + + + let deletedRecordIDs: [CKRecord.ID] = changes.compactMap { + switch $0 { + case .saveRecord(_): + return nil + case .deleteRecord(let recordID): + return recordID @unknown default: - break + return nil } } + let deletedRecordNames = deletedRecordIDs.map(\.recordName) - public func nextRecordZoneChangeBatch( - _ context: CKSyncEngine.SendChangesContext, - syncEngine: CKSyncEngine - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - await nextRecordZoneChangeBatch( - reason: context.reason, - options: context.options, - syncEngine: syncEngine + let metadataOfDeletions = await withErrorReporting { + try await userDatabase.read { db in + try SyncMetadata.where { $0.recordName.in(deletedRecordNames) } + .fetchAll(db) + } + } + ?? [] + + let shareRecordIDsToDelete = metadataOfDeletions.compactMap(\.share?.recordID) + + // TODO: short circuit this work if no shares are being deleted + + // A1 < B1 < C1 + // A2 < B2 < C2 + + let recordNamesWithRootRecordName = try! await userDatabase.read { db in + try With { + SyncMetadata + .where { $0.parentRecordName.is(nil) && $0.recordName.in(deletedRecordNames) } + .select { + RecordNameWithRootRecordName.Columns( + parentRecordName: $0.parentRecordName, + recordName: $0.recordName, + lastKnownServerRecord: $0.lastKnownServerRecord, + rootRecordName: $0.recordName, + rootLastKnownServerRecord: $0.lastKnownServerRecord + ) + } + .union( + all: true, + SyncMetadata + .join(RecordNameWithRootRecordName.all) { $1.recordName.is($0.parentRecordName) } + .select { metadata, tree in + RecordNameWithRootRecordName.Columns( + parentRecordName: metadata.parentRecordName, + recordName: metadata.recordName, + lastKnownServerRecord: metadata.lastKnownServerRecord, + rootRecordName: tree.rootRecordName, + rootLastKnownServerRecord: tree.lastKnownServerRecord + ) + } + ) + } query: { + RecordNameWithRootRecordName + .where { $0.recordName.in(deletedRecordNames) } + } + .fetchAll(db) + } + + for recordNameWithRootRecord in recordNamesWithRootRecordName { + guard + let lastKnownServerRecord = recordNameWithRootRecord.lastKnownServerRecord, + let rootLastKnownServerRecord = recordNameWithRootRecord.rootLastKnownServerRecord + else { continue } + guard let rootShareRecordID = rootLastKnownServerRecord.share?.recordID + else { continue } + guard shareRecordIDsToDelete.contains(rootShareRecordID) + else { continue } + guard + recordNameWithRootRecord.parentRecordName != nil + else { continue } + // If we get here we are looking at a non-root record whose root is also being deleted + // _and_ whose root share is also being deleted. + 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 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) + let allChanges = await pendingRecordZoneChanges(options: options, syncEngine: syncEngine) guard !allChanges.isEmpty else { return nil } @@ -690,31 +790,6 @@ } #endif - let deletedRecordNames: [String] = changes.compactMap { - switch $0 { - case .saveRecord(_): - return nil - case .deleteRecord(let recordID): - return recordID.recordName - @unknown default: - return nil - } - } - - // let shares = get all shares - // let deletes = get all deletes - // CTE MAGIC HERE: get all root records that are a parent to the deletes and whose share is contained in the shares - // remove those deletions from - - await withErrorReporting { - try await userDatabase.write { db in - try SyncMetadata - .where { $0.recordName.in(deletedRecordNames) } - .delete() - .execute(db) - } - } - let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in var missingTable: CKRecord.ID? var missingRecord: CKRecord.ID? @@ -1204,6 +1279,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 } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 58400280..7a1b591f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -65,7 +65,8 @@ QueryValue.columns.parentRecordType, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns._lastKnownServerRecordAllFields, QueryValue.columns.share, - QueryValue.columns.isShared, QueryValue.columns.isDeleted, QueryValue.columns.userModificationDate, + QueryValue.columns.isShared, QueryValue.columns.isDeleted, + QueryValue.columns.userModificationDate, ] } public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] { @@ -226,4 +227,74 @@ self.lastKnownServerRecord = lastKnownServerRecord } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension RecordNameWithRootRecordName { + public struct Columns: StructuredQueriesCore.QueryExpression { + public typealias QueryValue = RecordNameWithRootRecordName + public let queryFragment: StructuredQueriesCore.QueryFragment + public init( + parentRecordName: some StructuredQueriesCore.QueryExpression, + recordName: some StructuredQueriesCore.QueryExpression, + lastKnownServerRecord: some StructuredQueriesCore.QueryExpression, + rootRecordName: some StructuredQueriesCore.QueryExpression, + rootLastKnownServerRecord: some StructuredQueriesCore.QueryExpression + ) { + 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 = RecordNameWithRootRecordName + public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) + public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) + public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) + public let rootRecordName = StructuredQueriesCore.TableColumn("rootRecordName", keyPath: \QueryValue.rootRecordName) + public let rootLastKnownServerRecord = StructuredQueriesCore.TableColumn("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 RecordNameWithRootRecordName: StructuredQueriesCore.Table { + public nonisolated static var columns: TableColumns { + TableColumns() + } + public nonisolated static var tableName: String { + "recordNameWithRootRecordNames" + } + 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 + } +} + + #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index ae998bab..f0672fb4 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -69,14 +69,26 @@ 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, *) +// @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, *) +// @Table @Selection +struct RecordNameWithRootRecordName { + 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, *) extension SyncMetadata { diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 89aa7cc7..4131de88 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -204,7 +204,7 @@ private func rootServerRecord( @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension AncestorMetadata.Columns { - fileprivate init(_ metadata: SyncMetadata.TableColumns) { + init(_ metadata: SyncMetadata.TableColumns) { self.init( recordName: metadata.recordName, parentRecordName: metadata.parentRecordName, diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index 692c1d7b..e0d24b3c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -758,5 +758,26 @@ 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) + } } } From f6ae0a4c6b6657e8bed5d33baf3d256c354dfc3d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 12:10:29 -0500 Subject: [PATCH 05/14] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 20 ++++++++++--------- .../SharingGRDBCore/CloudKit/Triggers.swift | 6 +++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 509489a6..a8dd45ab 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -472,6 +472,7 @@ } var changes: [CKSyncEngine.PendingRecordZoneChange] = [] + // TODO: simplify let isOwner = zoneID.ownerName == CKCurrentUserDefaultName switch (share, isOwner) { case (let share?, true): @@ -486,6 +487,14 @@ ) case (let share?, false): changes.append(.deleteRecord(share.recordID)) + changes.append( + .deleteRecord( + CKRecord.ID( + recordName: recordName, + zoneID: zoneID + ) + ) + ) case (.none, _): changes.append( .deleteRecord( @@ -621,7 +630,8 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { 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 { @@ -647,9 +657,6 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { // TODO: short circuit this work if no shares are being deleted - // A1 < B1 < C1 - // A2 < B2 < C2 - let recordNamesWithRootRecordName = try! await userDatabase.read { db in try With { SyncMetadata @@ -693,11 +700,6 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { else { continue } guard shareRecordIDsToDelete.contains(rootShareRecordID) else { continue } - guard - recordNameWithRootRecord.parentRecordName != nil - else { continue } - // If we get here we are looking at a non-root record whose root is also being deleted - // _and_ whose root share is also being deleted. changes.removeAll(where: { $0 == .deleteRecord(lastKnownServerRecord.recordID)}) syncEngine.state.remove( pendingRecordZoneChanges: [.deleteRecord(lastKnownServerRecord.recordID)] diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 4131de88..328759ca 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -120,8 +120,8 @@ extension SyncMetadata { ifNotExists: true, after: .update { _, new in Values(.didUpdate(new)) - } when: { _, _ in - !SyncEngine.isSynchronizingChanges() + } when: { old, new in + old.isDeleted.eq(new.isDeleted) && !SyncEngine.isSynchronizingChanges() } ) @@ -136,7 +136,7 @@ extension SyncMetadata { share: new.share )) } when: { old, new in - !old.isDeleted && new.isDeleted //&& !SyncEngine.isSynchronizingChanges() + !old.isDeleted && new.isDeleted && !SyncEngine.isSynchronizingChanges() } ) } From 636ef912514538b6171ecdee13734e0bfb19392f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 16:26:31 -0500 Subject: [PATCH 06/14] wip --- Examples/Reminders/ReminderForm.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 82 ++++++++++++------- .../SyncMetadata+MacroExpansion.swift | 49 +++++++++++ .../CloudKit/SyncMetadata.swift | 8 ++ .../SharingGRDBCore/CloudKit/Triggers.swift | 40 ++++++++- 5 files changed, 147 insertions(+), 34 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/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index a8dd45ab..a0671f13 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, @@ -255,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) @@ -409,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) @@ -657,39 +659,42 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { // TODO: short circuit this work if no shares are being deleted - let recordNamesWithRootRecordName = try! await userDatabase.read { db in - try With { - SyncMetadata - .where { $0.parentRecordName.is(nil) && $0.recordName.in(deletedRecordNames) } - .select { - RecordNameWithRootRecordName.Columns( - parentRecordName: $0.parentRecordName, - recordName: $0.recordName, - lastKnownServerRecord: $0.lastKnownServerRecord, - rootRecordName: $0.recordName, - rootLastKnownServerRecord: $0.lastKnownServerRecord + let recordNamesWithRootRecordName = await withErrorReporting { + try await userDatabase.read { db in + try With { + SyncMetadata + .where { $0.parentRecordName.is(nil) && $0.recordName.in(deletedRecordNames) } + .select { + RecordNameWithRootRecordName.Columns( + parentRecordName: $0.parentRecordName, + recordName: $0.recordName, + lastKnownServerRecord: $0.lastKnownServerRecord, + rootRecordName: $0.recordName, + rootLastKnownServerRecord: $0.lastKnownServerRecord + ) + } + .union( + all: true, + SyncMetadata + .join(RecordNameWithRootRecordName.all) { $1.recordName.is($0.parentRecordName) } + .select { metadata, tree in + RecordNameWithRootRecordName.Columns( + parentRecordName: metadata.parentRecordName, + recordName: metadata.recordName, + lastKnownServerRecord: metadata.lastKnownServerRecord, + rootRecordName: tree.rootRecordName, + rootLastKnownServerRecord: tree.lastKnownServerRecord + ) + } ) - } - .union( - all: true, - SyncMetadata - .join(RecordNameWithRootRecordName.all) { $1.recordName.is($0.parentRecordName) } - .select { metadata, tree in - RecordNameWithRootRecordName.Columns( - parentRecordName: metadata.parentRecordName, - recordName: metadata.recordName, - lastKnownServerRecord: metadata.lastKnownServerRecord, - rootRecordName: tree.rootRecordName, - rootLastKnownServerRecord: tree.lastKnownServerRecord - ) - } - ) - } query: { - RecordNameWithRootRecordName - .where { $0.recordName.in(deletedRecordNames) } + } query: { + RecordNameWithRootRecordName + .where { $0.recordName.in(deletedRecordNames) } + } + .fetchAll(db) } - .fetchAll(db) } + ?? [] for recordNameWithRootRecord in recordNamesWithRootRecordName { guard @@ -1578,6 +1583,21 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { } } + 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", diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index 7a1b591f..fa735b98 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -296,5 +296,54 @@ } } +@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("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 f0672fb4..ea73d02a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -90,6 +90,14 @@ struct RecordNameWithRootRecordName { 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 328759ca..507087a1 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -14,10 +14,46 @@ extension PrimaryKeyedTable { } fileprivate static func afterInsert(parentForeignKey: ForeignKey?) -> TemporaryTrigger { - createTemporaryTrigger( + let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = + parentForeignKey + .map { (#""new".\#(quote: $0.from)"#, "\(bind: $0.table)") } + ?? ("NULL", "NULL") + + return createTemporaryTrigger( "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", ifNotExists: true, - after: .insert { new in SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } + after: .insert { new in + 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))") + } + } + + SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) + } ) } From b2a38115b2bda5dc52babb78069b0ef6eafd3525 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 16:33:22 -0500 Subject: [PATCH 07/14] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 55 ++----- .../CloudKitTests/CloudKitTests.swift | 3 +- .../CloudKitTests/TriggerTests.swift | 148 +++++++++++++++++- 3 files changed, 159 insertions(+), 47 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index a0671f13..3131fc14 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -442,11 +442,7 @@ try await setUpSyncEngine() } - func didUpdate( - recordName: String, - zoneID: CKRecordZone.ID?, - share _: CKShare? - ) { + func didUpdate(recordName: String,zoneID: CKRecordZone.ID?) { let zoneID = zoneID ?? defaultZone.zoneID let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -463,49 +459,21 @@ ) } - func didDelete( - recordName: String, - zoneID: CKRecordZone.ID?, - share: CKShare? - ) { + 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] = [] - - // TODO: simplify - let isOwner = zoneID.ownerName == CKCurrentUserDefaultName - switch (share, isOwner) { - case (let share?, true): - changes.append(.deleteRecord(share.recordID)) - changes.append( - .deleteRecord( - CKRecord.ID( - recordName: recordName, - zoneID: zoneID - ) + var changes: [CKSyncEngine.PendingRecordZoneChange] = [ + .deleteRecord( + CKRecord.ID( + recordName: recordName, + zoneID: zoneID ) ) - case (let share?, false): + ] + if let share { changes.append(.deleteRecord(share.recordID)) - changes.append( - .deleteRecord( - CKRecord.ID( - recordName: recordName, - zoneID: zoneID - ) - ) - ) - case (.none, _): - changes.append( - .deleteRecord( - CKRecord.ID( - recordName: recordName, - zoneID: zoneID - ) - ) - ) } syncEngine?.state.add(pendingRecordZoneChanges: changes) } @@ -1551,11 +1519,10 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension DatabaseFunction { fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { recordName, zoneID, share in + Self("didUpdate") { recordName, zoneID, _ in syncEngine.didUpdate( recordName: recordName, - zoneID: zoneID, - share: share + zoneID: zoneID ) } } 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/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index ba6173b2..ed276f49 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -20,7 +20,7 @@ extension BaseCloudKitTests { [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") 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" @@ -60,7 +60,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_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" @@ -285,6 +285,18 @@ extension BaseCloudKitTests { 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' @@ -296,6 +308,18 @@ extension BaseCloudKitTests { 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' @@ -307,6 +331,18 @@ extension BaseCloudKitTests { 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 @@ -318,6 +354,18 @@ extension BaseCloudKitTests { 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' @@ -329,6 +377,18 @@ extension BaseCloudKitTests { 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' @@ -340,6 +400,18 @@ extension BaseCloudKitTests { 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 @@ -351,6 +423,18 @@ extension BaseCloudKitTests { 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 @@ -362,6 +446,18 @@ extension BaseCloudKitTests { 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' @@ -373,6 +469,18 @@ extension BaseCloudKitTests { 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' @@ -384,6 +492,18 @@ extension BaseCloudKitTests { 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' @@ -395,6 +515,18 @@ extension BaseCloudKitTests { 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 @@ -406,6 +538,18 @@ extension BaseCloudKitTests { 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 From 3146e5c6232bbf44b9eb03bd5e8ec88974b8fede Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 16:46:15 -0500 Subject: [PATCH 08/14] wip --- .../SharingGRDBCore/CloudKit/Triggers.swift | 438 +++++++++--------- .../Articles/CloudKitSharing.md | 14 +- 2 files changed, 233 insertions(+), 219 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 507087a1..30d6d786 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -1,251 +1,269 @@ #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), - afterDeleteFromUser, - afterDeleteFromSyncEngine, - ] - } + import CloudKit + import Foundation - fileprivate static func afterInsert(parentForeignKey: ForeignKey?) -> TemporaryTrigger { - let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = - parentForeignKey - .map { (#""new".\#(quote: $0.from)"#, "\(bind: $0.table)") } - ?? ("NULL", "NULL") + @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, + ] + } - return createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", - ifNotExists: true, - after: .insert { new in - With { + fileprivate static func afterInsert(parentForeignKey: ForeignKey?) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", + ifNotExists: true, + after: .insert { new in + checkWritePermissions(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(parentForeignKey: parentForeignKey) + SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) + } + ) + } + + fileprivate static func afterDeleteFromUser(parentForeignKey: ForeignKey?) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_user", + ifNotExists: true, + after: .delete { old in + checkWritePermissions(parentForeignKey: parentForeignKey) 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 - ) + $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.parentRecordName.is(nil) - && !SQLQueryExpression("\(raw: String.sqliteDataCloudKitSchemaName)_hasPermission(\($0.share))") + $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) + && $0.recordType.eq(tableName) } + .delete() + } when: { _ in + SyncEngine.isSynchronizingChanges() } + ) + } + } - SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) + @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) + ) + } onConflict: { + ($0.recordPrimaryKey, $0.recordType) + } doUpdate: { + $0.parentRecordPrimaryKey = $1.parentRecordPrimaryKey + $0.parentRecordType = $1.parentRecordType + $0.userModificationDate = $1.userModificationDate } - ) + } } - fileprivate static func afterUpdate(parentForeignKey: ForeignKey?) -> TemporaryTrigger { - createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", - ifNotExists: true, - after: .update { _, new in SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } - ) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncMetadata { + static var callbackTriggers: [TemporaryTrigger] { + [ + afterInsertTrigger, + afterUpdateTrigger, + afterDeleteTrigger, + ] + } + + private enum ParentSyncMetadata: AliasName {} - fileprivate static var afterDeleteFromUser: TemporaryTrigger { - createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_user", + fileprivate static let afterInsertTrigger = createTemporaryTrigger( + "after_insert_on_sqlitedata_icloud_metadata", ifNotExists: true, - after: .delete { old in - SyncMetadata - .where { - $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) - && $0.recordType.eq(tableName) - } - .update { $0.isDeleted = true } + after: .insert { new in + Values(.didUpdate(new)) } when: { _ in !SyncEngine.isSynchronizingChanges() } ) - } - fileprivate static var afterDeleteFromSyncEngine: TemporaryTrigger { - createTemporaryTrigger( - "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_sync_engine", + fileprivate static let afterUpdateTrigger = createTemporaryTrigger( + "after_update_on_sqlitedata_icloud_metadata", ifNotExists: true, - after: .delete { old in - SyncMetadata - .where { - $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) - && $0.recordType.eq(tableName) - } - .delete() - } when: { _ in - SyncEngine.isSynchronizingChanges() + after: .update { _, new in + Values(.didUpdate(new)) + } when: { old, new in + old.isDeleted.eq(new.isDeleted) && !SyncEngine.isSynchronizingChanges() + } + ) + + fileprivate static let afterDeleteTrigger = 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 + ) + ) + } 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, + // TODO: separate lastKnownServerRecord from rootRecord + 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: { old, new in - old.isDeleted.eq(new.isDeleted) && !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: .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, *) + fileprivate static func didDelete( + recordName: some QueryExpression, + lastKnownServerRecord: some QueryExpression, + share: some QueryExpression + ) -> Self { + Self( + "\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord), \(share))" + ) } - ) -} + } -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, - // TODO: separate lastKnownServerRecord from rootRecord - lastKnownServerRecord: new.lastKnownServerRecord - ?? rootServerRecord(recordName: new.recordName), - share: new.share - ) + private func isUpdatingWithServerRecord() -> SQLQueryExpression { + SQLQueryExpression("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") } @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))") + private func checkWritePermissions( + parentForeignKey: ForeignKey? + ) -> some StructuredQueriesCore.Statement { + let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = + parentForeignKey + .map { (#""new".\#(quote: $0.from)"#, "\(bind: $0.table)") } + ?? ("NULL", "NULL") + + 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, *) - fileprivate static func didDelete( - recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression, - share: some QueryExpression - ) -> Self { - Self("\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord), \(share))") + 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) } + } } -} - -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) } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + 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 { - 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, From 7117b5ce3c7b2d5c92ac229b64086dd8a5b6642a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 17:02:05 -0500 Subject: [PATCH 09/14] wip --- .../SharingGRDBCore/CloudKit/Triggers.swift | 9 +- .../CloudKitTests/TriggerTests.swift | 288 ++++++++++++++++++ .../Internal/BaseCloudKitTests.swift | 2 - 3 files changed, 293 insertions(+), 6 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 30d6d786..c4f16389 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -18,7 +18,7 @@ "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", ifNotExists: true, after: .insert { new in - checkWritePermissions(parentForeignKey: parentForeignKey) + checkWritePermissions(alias: "new", parentForeignKey: parentForeignKey) SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } ) @@ -29,7 +29,7 @@ "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", ifNotExists: true, after: .update { _, new in - checkWritePermissions(parentForeignKey: parentForeignKey) + checkWritePermissions(alias: "new", parentForeignKey: parentForeignKey) SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } ) @@ -40,7 +40,7 @@ "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_\(tableName)_from_user", ifNotExists: true, after: .delete { old in - checkWritePermissions(parentForeignKey: parentForeignKey) + checkWritePermissions(alias: "old", parentForeignKey: parentForeignKey) SyncMetadata .where { $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) @@ -196,11 +196,12 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func checkWritePermissions( + alias: String, parentForeignKey: ForeignKey? ) -> some StructuredQueriesCore.Statement { let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = parentForeignKey - .map { (#""new".\#(quote: $0.from)"#, "\(bind: $0.table)") } + .map { (#"\#(quote: alias).\#(quote: $0.from)"#, "\(bind: $0.table)") } ?? ("NULL", "NULL") return With { diff --git a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift index ed276f49..f07a08fd 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -89,6 +89,18 @@ extension BaseCloudKitTests { 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')); @@ -106,6 +118,18 @@ extension BaseCloudKitTests { 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')); @@ -123,6 +147,18 @@ extension BaseCloudKitTests { 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')); @@ -140,6 +176,18 @@ extension BaseCloudKitTests { 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')); @@ -157,6 +205,18 @@ extension BaseCloudKitTests { 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')); @@ -174,6 +234,18 @@ extension BaseCloudKitTests { 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')); @@ -191,6 +263,18 @@ extension BaseCloudKitTests { 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')); @@ -208,6 +292,18 @@ extension BaseCloudKitTests { 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')); @@ -225,6 +321,18 @@ extension BaseCloudKitTests { 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')); @@ -242,6 +350,18 @@ extension BaseCloudKitTests { 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')); @@ -259,6 +379,18 @@ extension BaseCloudKitTests { 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')); @@ -276,6 +408,18 @@ extension BaseCloudKitTests { 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')); @@ -561,6 +705,18 @@ extension BaseCloudKitTests { 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' @@ -572,6 +728,18 @@ extension BaseCloudKitTests { 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' @@ -583,6 +751,18 @@ extension BaseCloudKitTests { 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 @@ -594,6 +774,18 @@ extension BaseCloudKitTests { 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' @@ -605,6 +797,18 @@ extension BaseCloudKitTests { 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' @@ -616,6 +820,18 @@ extension BaseCloudKitTests { 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 @@ -627,6 +843,18 @@ extension BaseCloudKitTests { 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 @@ -638,6 +866,18 @@ extension BaseCloudKitTests { 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' @@ -649,6 +889,18 @@ extension BaseCloudKitTests { 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' @@ -660,6 +912,18 @@ extension BaseCloudKitTests { 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' @@ -671,6 +935,18 @@ extension BaseCloudKitTests { 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 @@ -682,6 +958,18 @@ extension BaseCloudKitTests { 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 d947d2fc22372467de3ba4f4daf4d172fd8f6387 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 17:04:07 -0500 Subject: [PATCH 10/14] clean up --- .../CloudKit/Metadatabase.swift | 2 +- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 8 +- .../SyncMetadata+MacroExpansion.swift | 214 ++++++++++-------- .../CloudKit/SyncMetadata.swift | 60 ++--- .../SharingGRDBCore/CloudKit/Triggers.swift | 8 +- 5 files changed, 162 insertions(+), 130 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index 01a3c43c..fc4e2e90 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -63,7 +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, + "_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 3131fc14..62c279b1 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -633,7 +633,7 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { SyncMetadata .where { $0.parentRecordName.is(nil) && $0.recordName.in(deletedRecordNames) } .select { - RecordNameWithRootRecordName.Columns( + RecordWithRoot.Columns( parentRecordName: $0.parentRecordName, recordName: $0.recordName, lastKnownServerRecord: $0.lastKnownServerRecord, @@ -644,9 +644,9 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { .union( all: true, SyncMetadata - .join(RecordNameWithRootRecordName.all) { $1.recordName.is($0.parentRecordName) } + .join(RecordWithRoot.all) { $1.recordName.is($0.parentRecordName) } .select { metadata, tree in - RecordNameWithRootRecordName.Columns( + RecordWithRoot.Columns( parentRecordName: metadata.parentRecordName, recordName: metadata.recordName, lastKnownServerRecord: metadata.lastKnownServerRecord, @@ -656,7 +656,7 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { } ) } query: { - RecordNameWithRootRecordName + RecordWithRoot .where { $0.recordName.in(deletedRecordNames) } } .fetchAll(db) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift index fa735b98..52ae476f 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata+MacroExpansion.swift @@ -48,10 +48,10 @@ keyPath: \QueryValue.isShared ) } - public var isDeleted: StructuredQueriesCore.TableColumn { + public var _isDeleted: StructuredQueriesCore.TableColumn { StructuredQueriesCore.TableColumn( - "isDeleted", - keyPath: \QueryValue.isDeleted + "_isDeleted", + keyPath: \QueryValue._isDeleted ) } public let userModificationDate = StructuredQueriesCore.TableColumn( @@ -65,7 +65,7 @@ QueryValue.columns.parentRecordType, QueryValue.columns.parentRecordName, QueryValue.columns.lastKnownServerRecord, QueryValue.columns._lastKnownServerRecordAllFields, QueryValue.columns.share, - QueryValue.columns.isShared, QueryValue.columns.isDeleted, + QueryValue.columns.isShared, QueryValue.columns._isDeleted, QueryValue.columns.userModificationDate, ] } @@ -75,12 +75,12 @@ QueryValue.columns.parentRecordPrimaryKey, QueryValue.columns.parentRecordType, QueryValue.columns.lastKnownServerRecord, QueryValue.columns._lastKnownServerRecordAllFields, QueryValue.columns.share, - QueryValue.columns.isDeleted, + 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)" + "\(self.recordPrimaryKey), \(self.recordType), \(self.recordName), \(self.parentRecordPrimaryKey), \(self.parentRecordType), \(self.parentRecordName), \(self.lastKnownServerRecord), \(self._lastKnownServerRecordAllFields), \(self.share), \(self.isShared), \(self._isDeleted), \(self.userModificationDate)" } } } @@ -106,7 +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 _isDeleted = try decoder.decode(Bool.self) let userModificationDate = try decoder.decode(Date.self) guard let recordPrimaryKey else { throw QueryDecodingError.missingRequiredColumn @@ -129,7 +129,7 @@ guard let isShared else { throw QueryDecodingError.missingRequiredColumn } - guard let isDeleted else { + guard let _isDeleted else { throw QueryDecodingError.missingRequiredColumn } guard let userModificationDate else { @@ -142,7 +142,7 @@ self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields self.share = share self.isShared = isShared - self.isDeleted = isDeleted + self._isDeleted = _isDeleted self.userModificationDate = userModificationDate } } @@ -229,34 +229,59 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension RecordNameWithRootRecordName { + extension RecordWithRoot { public struct Columns: StructuredQueriesCore.QueryExpression { - public typealias QueryValue = RecordNameWithRootRecordName + public typealias QueryValue = RecordWithRoot public let queryFragment: StructuredQueriesCore.QueryFragment public init( parentRecordName: some StructuredQueriesCore.QueryExpression, recordName: some StructuredQueriesCore.QueryExpression, - lastKnownServerRecord: some StructuredQueriesCore.QueryExpression, + lastKnownServerRecord: some StructuredQueriesCore.QueryExpression< + CKRecord?.SystemFieldsRepresentation + >, rootRecordName: some StructuredQueriesCore.QueryExpression, - rootLastKnownServerRecord: 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" - """ + \(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 = RecordNameWithRootRecordName - public let parentRecordName = StructuredQueriesCore.TableColumn("parentRecordName", keyPath: \QueryValue.parentRecordName) - public let recordName = StructuredQueriesCore.TableColumn("recordName", keyPath: \QueryValue.recordName) - public let lastKnownServerRecord = StructuredQueriesCore.TableColumn("lastKnownServerRecord", keyPath: \QueryValue.lastKnownServerRecord) - public let rootRecordName = StructuredQueriesCore.TableColumn("rootRecordName", keyPath: \QueryValue.rootRecordName) - public let rootLastKnownServerRecord = StructuredQueriesCore.TableColumn("rootLastKnownServerRecord", keyPath: \QueryValue.rootLastKnownServerRecord) + 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] + [ + 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] + [ + 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)" @@ -264,86 +289,91 @@ } } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) nonisolated extension RecordNameWithRootRecordName: StructuredQueriesCore.Table { - public nonisolated static var columns: TableColumns { - TableColumns() - } - public nonisolated static var tableName: String { - "recordNameWithRootRecordNames" - } - 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 + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + nonisolated extension RecordWithRoot: StructuredQueriesCore.Table { + public nonisolated static var columns: TableColumns { + TableColumns() } - guard let rootRecordName else { - throw QueryDecodingError.missingRequiredColumn + public nonisolated static var tableName: String { + "recordWithRoots" } - guard let rootLastKnownServerRecord else { - throw QueryDecodingError.missingRequiredColumn + 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 } - 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" - """ + @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("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)" + 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 + @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 } - self.share = share } -} - - #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift index ea73d02a..3a4833a8 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncMetadata.swift @@ -60,7 +60,9 @@ // @Column(as: CKShare?.SystemFieldsRepresentation.self) public var share: CKShare? - public var isDeleted = false + /// 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 @@ -69,34 +71,34 @@ 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, *) -// @Table @Selection -struct RecordNameWithRootRecordName { - 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, *) + // @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, *) + // @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 { diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index c4f16389..5563a577 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -46,7 +46,7 @@ $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) && $0.recordType.eq(tableName) } - .update { $0.isDeleted = true } + .update { $0._isDeleted = true } } when: { _ in !SyncEngine.isSynchronizingChanges() } @@ -128,14 +128,14 @@ after: .update { _, new in Values(.didUpdate(new)) } when: { old, new in - old.isDeleted.eq(new.isDeleted) && !SyncEngine.isSynchronizingChanges() + old._isDeleted.eq(new._isDeleted) && !SyncEngine.isSynchronizingChanges() } ) fileprivate static let afterDeleteTrigger = createTemporaryTrigger( "after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, - after: .update(of: \.isDeleted) { _, new in + after: .update(of: \._isDeleted) { _, new in Values( .didDelete( recordName: new.recordName, @@ -145,7 +145,7 @@ ) ) } when: { old, new in - !old.isDeleted && new.isDeleted && !SyncEngine.isSynchronizingChanges() + !old._isDeleted && new._isDeleted && !SyncEngine.isSynchronizingChanges() } ) } From 05ce261b1e60ee7f3c9f048218f06ea54f0813a6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 17:06:19 -0500 Subject: [PATCH 11/14] wip --- .../ForeignKeyConstraintTests.swift | 34 +++++++++++++++++++ .../CloudKitTests/NewTableSyncTests.swift | 4 +-- .../CloudKitTests/SharingTests.swift | 2 +- .../CloudKitTests/TriggerTests.swift | 30 ++++++++-------- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift index e0d24b3c..2d679e4c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -778,6 +778,40 @@ 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(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 b480d3d6..b580e573 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/NewTableSyncTests.swift @@ -89,7 +89,7 @@ extension BaseCloudKitTests { title: "Write blog post" ), share: nil, - isDeleted: false, + _isDeleted: false, isShared: false, userModificationDate: Date(1970-01-01T00:00:00.000Z) ), @@ -115,7 +115,7 @@ extension BaseCloudKitTests { title: "Personal" ), share: nil, - isDeleted: false, + _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 a7981c55..775e075b 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -294,7 +294,7 @@ extension BaseCloudKitTests { title: "Personal" ), share: nil, - isDeleted: false, + _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 f07a08fd..2118cd01 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/TriggerTests.swift @@ -19,8 +19,8 @@ extension BaseCloudKitTests { [ [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 + 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" @@ -60,7 +60,7 @@ extension BaseCloudKitTests { [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" @@ -102,7 +102,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetDefaults')); END """, @@ -131,7 +131,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'childWithOnDeleteSetNulls')); END """, @@ -160,7 +160,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelAs')); END """, @@ -189,7 +189,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelBs')); END """, @@ -218,7 +218,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'modelCs')); END """, @@ -247,7 +247,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'parents')); END """, @@ -276,7 +276,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminderTags')); END """, @@ -305,7 +305,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListAssets')); END """, @@ -334,7 +334,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersListPrivates')); END """, @@ -363,7 +363,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'remindersLists')); END """, @@ -392,7 +392,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."id") AND ("sqlitedata_icloud_metadata"."recordType" = 'reminders')); END """, @@ -421,7 +421,7 @@ extension BaseCloudKitTests { FROM "rootShares" WHERE (("rootShares"."parentRecordName" IS NULL) AND NOT (sqlitedata_icloud_hasPermission("rootShares"."share"))); UPDATE "sqlitedata_icloud_metadata" - SET "isDeleted" = 1 + SET "_isDeleted" = 1 WHERE (("sqlitedata_icloud_metadata"."recordPrimaryKey" = "old"."title") AND ("sqlitedata_icloud_metadata"."recordType" = 'tags')); END """, From 40e076a8be596084fc003e0dcb886fce245d2b24 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 17:15:26 -0500 Subject: [PATCH 12/14] wip --- Sources/SharingGRDBCore/CloudKit/SyncEngine.swift | 10 ++++------ Sources/SharingGRDBCore/CloudKit/Triggers.swift | 1 - 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 62c279b1..a8f0d2b6 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -625,9 +625,7 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { let shareRecordIDsToDelete = metadataOfDeletions.compactMap(\.share?.recordID) - // TODO: short circuit this work if no shares are being deleted - - let recordNamesWithRootRecordName = await withErrorReporting { + let recordsWithRoot = await withErrorReporting { try await userDatabase.read { db in try With { SyncMetadata @@ -664,10 +662,10 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { } ?? [] - for recordNameWithRootRecord in recordNamesWithRootRecordName { + for recordWithRoot in recordsWithRoot { guard - let lastKnownServerRecord = recordNameWithRootRecord.lastKnownServerRecord, - let rootLastKnownServerRecord = recordNameWithRootRecord.rootLastKnownServerRecord + let lastKnownServerRecord = recordWithRoot.lastKnownServerRecord, + let rootLastKnownServerRecord = recordWithRoot.rootLastKnownServerRecord else { continue } guard let rootShareRecordID = rootLastKnownServerRecord.share?.recordID else { continue } diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index 5563a577..b6cc10f5 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -160,7 +160,6 @@ ) -> Self { .didUpdate( recordName: new.recordName, - // TODO: separate lastKnownServerRecord from rootRecord lastKnownServerRecord: new.lastKnownServerRecord ?? rootServerRecord(recordName: new.recordName), share: new.share From bfaa561f37ed7fa8d704fe5301d16f08177be6bc Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 14 Aug 2025 20:53:15 -0500 Subject: [PATCH 13/14] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 311 +++++++++--------- 1 file changed, 157 insertions(+), 154 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index a8f0d2b6..d900dadb 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -442,7 +442,7 @@ try await setUpSyncEngine() } - func didUpdate(recordName: String,zoneID: CKRecordZone.ID?) { + func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { let zoneID = zoneID ?? defaultZone.zoneID let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared @@ -531,174 +531,80 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { - public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { - guard let event = Event(event) - else { - reportIssue("Unrecognized event received: \(event)") - return + extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { + 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) } - 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, syncEngine: syncEngine) - case .stateUpdate(let stateSerialization): - handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) - case .fetchedDatabaseChanges(let modifications, let deletions): - await handleFetchedDatabaseChanges( - modifications: modifications, - deletions: deletions, - syncEngine: syncEngine - ) - case .sentDatabaseChanges: - break - case .fetchedRecordZoneChanges(let modifications, let deletions): - await handleFetchedRecordZoneChanges( - modifications: modifications, - deletions: deletions, - syncEngine: syncEngine - ) - case .sentRecordZoneChanges( - let savedRecords, - let failedRecordSaves, - let deletedRecordIDs, - let failedRecordDeletes - ): - await handleSentRecordZoneChanges( - savedRecords: savedRecords, - failedRecordSaves: failedRecordSaves, - deletedRecordIDs: deletedRecordIDs, - failedRecordDeletes: failedRecordDeletes, - syncEngine: syncEngine - ) - case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .willFetchChanges, + package func handleEvent(_ event: Event, syncEngine: any SyncEngineProtocol) async { + logger.log(event, syncEngine: syncEngine) + + switch event { + case .accountChange(let changeType): + await handleAccountChange(changeType: changeType, syncEngine: syncEngine) + case .stateUpdate(let stateSerialization): + handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) + case .fetchedDatabaseChanges(let modifications, let deletions): + await handleFetchedDatabaseChanges( + modifications: modifications, + deletions: deletions, + syncEngine: syncEngine + ) + case .sentDatabaseChanges: + break + case .fetchedRecordZoneChanges(let modifications, let deletions): + await handleFetchedRecordZoneChanges( + modifications: modifications, + deletions: deletions, + syncEngine: syncEngine + ) + case .sentRecordZoneChanges( + let savedRecords, + let failedRecordSaves, + let deletedRecordIDs, + let failedRecordDeletes + ): + await handleSentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes, + 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 - ) - } - - 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 + break @unknown default: - return nil - } - } - let deletedRecordNames = deletedRecordIDs.map(\.recordName) - - let metadataOfDeletions = await withErrorReporting { - try await userDatabase.read { db in - try SyncMetadata.where { $0.recordName.in(deletedRecordNames) } - .fetchAll(db) - } - } - ?? [] - - let shareRecordIDsToDelete = metadataOfDeletions.compactMap(\.share?.recordID) - - let recordsWithRoot = await withErrorReporting { - try await userDatabase.read { db in - 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) + break } } - ?? [] - 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)] + public func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + await nextRecordZoneChangeBatch( + reason: context.reason, + options: context.options, + syncEngine: syncEngine ) } - await withErrorReporting { - try await userDatabase.write { db in - try SyncMetadata - .where { $0.recordName.in(deletedRecordNames) } - .delete() - .execute(db) - } - } - - return changes - } - package func nextRecordZoneChangeBatch( reason: CKSyncEngine.SyncReason = .scheduled, options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(scope: .all), syncEngine: any SyncEngineProtocol ) async -> CKSyncEngine.RecordZoneChangeBatch? { - let allChanges = await pendingRecordZoneChanges(options: options, syncEngine: syncEngine) - 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 @@ -853,6 +759,102 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { 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 = + await withErrorReporting { + try await userDatabase.read { db in + try SyncMetadata.where { $0.recordName.in(deletedRecordNames) } + .fetchAll(db) + } + } + ?? [] + + let shareRecordIDsToDelete = metadataOfDeletions.compactMap(\.share?.recordID) + + let recordsWithRoot = + await withErrorReporting { + try await userDatabase.read { db in + 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) + } + } + ?? [] + + 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 @@ -1557,8 +1559,9 @@ extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate { } guard let share else { return true } - let hasPermission = share.publicPermission == .readWrite || - share.currentUserParticipant?.permission == .readWrite + let hasPermission = + share.publicPermission == .readWrite + || share.currentUserParticipant?.permission == .readWrite return hasPermission } } From 77c4ac467b3211ee251db907792774615c82713f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 15 Aug 2025 12:51:26 -0500 Subject: [PATCH 14/14] wip --- .../SharingGRDBCore/CloudKit/SyncEngine.swift | 81 +++++++++---------- .../SharingGRDBCore/CloudKit/Triggers.swift | 47 ++++++----- 2 files changed, 69 insertions(+), 59 deletions(-) diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index d900dadb..005b4794 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -779,55 +779,54 @@ } let deletedRecordNames = deletedRecordIDs.map(\.recordName) - let metadataOfDeletions = + let (metadataOfDeletions, recordsWithRoot): ([SyncMetadata], [RecordWithRoot]) = await withErrorReporting { try await userDatabase.read { db in - try SyncMetadata.where { $0.recordName.in(deletedRecordNames) } + 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) - let recordsWithRoot = - await withErrorReporting { - try await userDatabase.read { db in - 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) - } - } - ?? [] - for recordWithRoot in recordsWithRoot { guard let lastKnownServerRecord = recordWithRoot.lastKnownServerRecord, diff --git a/Sources/SharingGRDBCore/CloudKit/Triggers.swift b/Sources/SharingGRDBCore/CloudKit/Triggers.swift index b6cc10f5..65f11912 100644 --- a/Sources/SharingGRDBCore/CloudKit/Triggers.swift +++ b/Sources/SharingGRDBCore/CloudKit/Triggers.swift @@ -18,7 +18,7 @@ "\(String.sqliteDataCloudKitSchemaName)_after_insert_on_\(tableName)", ifNotExists: true, after: .insert { new in - checkWritePermissions(alias: "new", parentForeignKey: parentForeignKey) + checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } ) @@ -29,18 +29,20 @@ "\(String.sqliteDataCloudKitSchemaName)_after_update_on_\(tableName)", ifNotExists: true, after: .update { _, new in - checkWritePermissions(alias: "new", parentForeignKey: parentForeignKey) + checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } ) } - fileprivate static func afterDeleteFromUser(parentForeignKey: ForeignKey?) -> TemporaryTrigger { + 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) + checkWritePermissions(alias: old, parentForeignKey: parentForeignKey) SyncMetadata .where { $0.recordPrimaryKey.eq(SQLQueryExpression("\(old.primaryKey)")) @@ -73,14 +75,14 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - fileprivate static func upsert( - new: TemporaryTrigger.Operation.New, + fileprivate static func upsert( + new: StructuredQueriesCore.TableAlias.TableColumns, parentForeignKey: ForeignKey?, ) -> some StructuredQueriesCore.Statement { - let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = - parentForeignKey - .map { (#""new".\#(quote: $0.from)"#, "\(bind: $0.table)") } - ?? ("NULL", "NULL") + let (parentRecordPrimaryKey, parentRecordType) = parentFields( + alias: new, + parentForeignKey: parentForeignKey + ) return insert { ($0.recordPrimaryKey, $0.recordType, $0.parentRecordPrimaryKey, $0.parentRecordType) } select: { @@ -106,7 +108,7 @@ [ afterInsertTrigger, afterUpdateTrigger, - afterDeleteTrigger, + afterSoftDeleteTrigger, ] } @@ -132,7 +134,7 @@ } ) - fileprivate static let afterDeleteTrigger = createTemporaryTrigger( + fileprivate static let afterSoftDeleteTrigger = createTemporaryTrigger( "after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, after: .update(of: \._isDeleted) { _, new in @@ -193,15 +195,24 @@ 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") + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - private func checkWritePermissions( - alias: String, + private func checkWritePermissions( + alias: StructuredQueriesCore.TableAlias.TableColumns, parentForeignKey: ForeignKey? ) -> some StructuredQueriesCore.Statement { - let (parentRecordPrimaryKey, parentRecordType): (QueryFragment, QueryFragment) = - parentForeignKey - .map { (#"\#(quote: alias).\#(quote: $0.from)"#, "\(bind: $0.table)") } - ?? ("NULL", "NULL") + let (parentRecordPrimaryKey, parentRecordType) = parentFields( + alias: alias, + parentForeignKey: parentForeignKey + ) return With { SyncMetadata