From d787c4d3929db3a75030c1a3cf375c8dcc3b767c Mon Sep 17 00:00:00 2001 From: Skwiggs <6209874+MrSkwiggs@users.noreply.github.com> Date: Mon, 25 May 2026 17:08:39 +0200 Subject: [PATCH 1/3] Use unscoped queries This fixes scenarios where a changed item would not show up in the list of changes due to to their default scope filtering them out (i.e soft-deleted) --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 871c7999..1099cad1 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1181,6 +1181,7 @@ try await userDatabase.read { db in result = try T + .unscoped .where { #sql("\($0.primaryKey) = \(bind: metadata.recordPrimaryKey)") } @@ -1427,7 +1428,7 @@ else { continue } func open(_: some SynchronizableTable) { withErrorReporting(.sqliteDataCloudKitFailure) { - try T.where { #sql("\($0.primaryKey)").in(primaryKeys) }.delete().execute(db) + try T.unscoped.where { #sql("\($0.primaryKey)").in(primaryKeys) }.delete().execute(db) } } open(table) @@ -1449,7 +1450,7 @@ func open(_: some SynchronizableTable) { withErrorReporting(.sqliteDataCloudKitFailure) { pendingRecordZoneChanges.append( - contentsOf: try T.select(\._recordName).fetchAll(db).map { + contentsOf: try T.unscoped.select(\._recordName).fetchAll(db).map { .saveRecord(CKRecord.ID(recordName: $0, zoneID: zoneID)) } ) @@ -1483,6 +1484,7 @@ await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.write { db in try T + .unscoped .where { #sql("\($0.primaryKey)").in( SyncMetadata.findAll(recordIDs) @@ -1708,6 +1710,7 @@ switch foreignKey.onDelete { case .cascade: try T + .unscoped .where { #sql("\($0.primaryKey) = \(bind: recordPrimaryKey)") } .delete() .execute(db) @@ -1767,6 +1770,7 @@ } catch let error as CKError where error.code == .unknownItem { try await userDatabase.write { db in try T + .unscoped .where { #sql("\($0.primaryKey) = \(bind: recordPrimaryKey)") } .delete() .execute(db) @@ -1949,7 +1953,7 @@ var columnNames: [String] = T.TableColumns.writableColumns.map(\.name) if !force, let allFields = metadata._lastKnownServerRecordAllFields, - let row = try T.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) + let row = try T.unscoped.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) { serverRecord.update( with: allFields, From 4fbee529b6f4fc0cd8a0130f96086ca3f9abf7d1 Mon Sep 17 00:00:00 2001 From: Skwiggs <6209874+MrSkwiggs@users.noreply.github.com> Date: Mon, 25 May 2026 17:45:57 +0200 Subject: [PATCH 2/3] Add tests to guard against regressions --- .../CloudKitTests/ScopedTableSyncTests.swift | 226 ++++++++++++++++++ Tests/SQLiteDataTests/Internal/Schema.swift | 18 ++ 2 files changed, 244 insertions(+) create mode 100644 Tests/SQLiteDataTests/CloudKitTests/ScopedTableSyncTests.swift diff --git a/Tests/SQLiteDataTests/CloudKitTests/ScopedTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ScopedTableSyncTests.swift new file mode 100644 index 00000000..e29774de --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/ScopedTableSyncTests.swift @@ -0,0 +1,226 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SQLiteDataTestSupport + import SnapshotTestingCustomDump + import Testing + + // Scoped-out rows must remain reachable by every SyncEngine primary-key path. + @MainActor + @Suite + struct ScopedTableSyncTests { + struct Fixture { + let userDatabase: UserDatabase + let syncEngine: SyncEngine + let container: MockCloudContainer + let zoneID: CKRecordZone.ID + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + static func makeFixture() async throws -> Fixture { + let containerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" + let database = try DatabasePool( + path: URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite").path() + ) + try await database.write { db in + try #sql( + """ + CREATE TABLE "scopedModels" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "isDeleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """ + ) + .execute(db) + } + let userDatabase = UserDatabase(database: database) + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + let container = MockCloudContainer( + containerIdentifier: containerIdentifier, + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase + ) + privateDatabase.set(container: container) + sharedDatabase.set(container: container) + let syncEngine = try await SyncEngine( + container: container, + userDatabase: userDatabase, + tables: [SynchronizedTable(for: ScopedModel.self)] + ) + let currentUser = CKRecord.ID(recordName: "currentUser") + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUser)), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUser)), + syncEngine: syncEngine.shared + ) + try await syncEngine.processPendingDatabaseChanges(scope: .private) + return Fixture( + userDatabase: userDatabase, + syncEngine: syncEngine, + container: container, + zoneID: syncEngine.defaultZone.zoneID + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func outgoingSaveLookupIncludesScopedOutRow() async throws { + let fx = try await Self.makeFixture() + try await fx.userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "Important", isDeleted: false) } + } + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + try await fx.userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: fx.container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:scopedModels/zone/__defaultOwner__), + recordType: "scopedModels", + parent: nil, + share: nil, + id: 1, + isDeleted: 1, + title: "Important" + ) + ] + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func zoneDeletionRemovesScopedOutRow() async throws { + let fx = try await Self.makeFixture() + try await fx.userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } + } + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + try await fx.userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } + + try await fx.syncEngine.modifyRecordZones( + scope: .private, + deleting: [fx.zoneID] + ).notify() + try await fx.syncEngine.processPendingDatabaseChanges(scope: .private) + + try await fx.userDatabase.read { db in + try #expect(ScopedModel.unscoped.fetchAll(db) == []) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func encryptedDataResetReuploadsScopedOutRow() async throws { + let fx = try await Self.makeFixture() + try await fx.userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } + } + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + try await fx.userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + + await fx.syncEngine.handleEvent( + SyncEngine.Event.fetchedDatabaseChanges( + modifications: [], + deletions: [(fx.zoneID, .encryptedDataReset)] + ), + syncEngine: fx.syncEngine.private + ) + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await fx.userDatabase.read { db in + try #expect(ScopedModel.unscoped.fetchAll(db).count == 1) + } + assertInlineSnapshot(of: fx.container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:scopedModels/zone/__defaultOwner__), + recordType: "scopedModels", + parent: nil, + share: nil, + id: 1, + isDeleted: 1, + title: "x" + ) + ] + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverDeleteRemovesScopedOutRow() async throws { + let fx = try await Self.makeFixture() + try await fx.userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } + } + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + try await fx.userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } + + try await fx.syncEngine.modifyRecords( + scope: .private, + deleting: [ScopedModel.recordID(for: 1)] + ).notify() + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await fx.userDatabase.read { db in + try #expect(ScopedModel.unscoped.fetchAll(db) == []) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverModificationMergesScopedOutRow() async throws { + let fx = try await Self.makeFixture() + try await fx.userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } + } + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + try await fx.userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } + try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + + let serverRecord = CKRecord( + recordType: ScopedModel.tableName, + recordID: ScopedModel.recordID(for: 1) + ) + serverRecord["id"] = 1 + serverRecord["title"] = "from-server" + serverRecord["isDeleted"] = 1 + await fx.syncEngine.handleEvent( + SyncEngine.Event.fetchedRecordZoneChanges( + modifications: [serverRecord], + deletions: [] + ), + syncEngine: fx.syncEngine.private + ) + + try await fx.userDatabase.read { db in + let rows = try ScopedModel.unscoped.fetchAll(db) + #expect(rows.count == 1) + #expect(rows.first?.isDeleted == true) + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index a9e965b9..f6aff6cf 100644 --- a/Tests/SQLiteDataTests/Internal/Schema.swift +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -73,6 +73,14 @@ import SQLiteData @Table struct UnsyncedModel: Equatable, Identifiable { let id: Int } +@Table struct ScopedModel: Equatable, Identifiable { + let id: Int + var title = "" + var isDeleted = false +} +extension ScopedModel { + static let all = Self.where { !$0.isDeleted } +} @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func database( @@ -228,6 +236,16 @@ func database( """ ) .execute(db) + try #sql( + """ + CREATE TABLE "scopedModels" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "isDeleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """ + ) + .execute(db) } return database } From 6033169a7ed0bb0578a46c824416b604091af9af Mon Sep 17 00:00:00 2001 From: Skwiggs <6209874+MrSkwiggs@users.noreply.github.com> Date: Mon, 25 May 2026 18:00:44 +0200 Subject: [PATCH 3/3] Run scoped table as part of base cloudkit tests --- .../CloudKitTests/CloudKitTests.swift | 33 ++ .../CloudKitTests/RecordTypeTests.swift | 33 ++ .../CloudKitTests/ScopedTableSyncTests.swift | 308 +++++++----------- .../CloudKitTests/TriggerTests.swift | 179 +++++++--- .../Internal/BaseCloudKitTests.swift | 1 + 5 files changed, 319 insertions(+), 235 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index d7332fc9..fa989369 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -365,6 +365,39 @@ type: "TEXT" ) ] + ), + [12]: RecordType( + tableName: "scopedModels", + schema: """ + CREATE TABLE "scopedModels" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "isDeleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INT" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isDeleted", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] ) ] """# diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 8e624b72..2891b3e2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -363,6 +363,39 @@ type: "TEXT" ) ] + ), + [12]: RecordType( + tableName: "scopedModels", + schema: """ + CREATE TABLE "scopedModels" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "isDeleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INT" + ), + [1]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isDeleted", + isNotNull: true, + type: "INTEGER" + ), + [2]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] ) ] """# diff --git a/Tests/SQLiteDataTests/CloudKitTests/ScopedTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ScopedTableSyncTests.swift index e29774de..17d68bb0 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ScopedTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ScopedTableSyncTests.swift @@ -8,218 +8,138 @@ import SnapshotTestingCustomDump import Testing - // Scoped-out rows must remain reachable by every SyncEngine primary-key path. - @MainActor - @Suite - struct ScopedTableSyncTests { - struct Fixture { - let userDatabase: UserDatabase - let syncEngine: SyncEngine - let container: MockCloudContainer - let zoneID: CKRecordZone.ID - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - static func makeFixture() async throws -> Fixture { - let containerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" - let database = try DatabasePool( - path: URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite").path() - ) - try await database.write { db in - try #sql( + extension BaseCloudKitTests { + @MainActor + final class ScopedTableSyncTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func outgoingSaveLookupIncludesScopedOutRow() async throws { + try await userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "Important", isDeleted: false) } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { """ - CREATE TABLE "scopedModels" ( - "id" INT PRIMARY KEY NOT NULL, - "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', - "isDeleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 - ) STRICT + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:scopedModels/zone/__defaultOwner__), + recordType: "scopedModels", + parent: nil, + share: nil, + id: 1, + isDeleted: 1, + title: "Important" + ) + ] + ) """ - ) - .execute(db) - } - let userDatabase = UserDatabase(database: database) - let privateDatabase = MockCloudDatabase(databaseScope: .private) - let sharedDatabase = MockCloudDatabase(databaseScope: .shared) - let container = MockCloudContainer( - containerIdentifier: containerIdentifier, - privateCloudDatabase: privateDatabase, - sharedCloudDatabase: sharedDatabase - ) - privateDatabase.set(container: container) - sharedDatabase.set(container: container) - let syncEngine = try await SyncEngine( - container: container, - userDatabase: userDatabase, - tables: [SynchronizedTable(for: ScopedModel.self)] - ) - let currentUser = CKRecord.ID(recordName: "currentUser") - await syncEngine.handleEvent( - .accountChange(changeType: .signIn(currentUser: currentUser)), - syncEngine: syncEngine.private - ) - await syncEngine.handleEvent( - .accountChange(changeType: .signIn(currentUser: currentUser)), - syncEngine: syncEngine.shared - ) - try await syncEngine.processPendingDatabaseChanges(scope: .private) - return Fixture( - userDatabase: userDatabase, - syncEngine: syncEngine, - container: container, - zoneID: syncEngine.defaultZone.zoneID - ) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func outgoingSaveLookupIncludesScopedOutRow() async throws { - let fx = try await Self.makeFixture() - try await fx.userDatabase.userWrite { db in - try db.seed { ScopedModel(id: 1, title: "Important", isDeleted: false) } - } - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) - try await fx.userDatabase.userWrite { db in - try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) - } - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: fx.container.privateCloudDatabase, as: .customDump) { - """ - MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:scopedModels/zone/__defaultOwner__), - recordType: "scopedModels", - parent: nil, - share: nil, - id: 1, - isDeleted: 1, - title: "Important" - ) - ] - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func zoneDeletionRemovesScopedOutRow() async throws { - let fx = try await Self.makeFixture() - try await fx.userDatabase.userWrite { db in - try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } - } - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) - try await fx.userDatabase.userWrite { db in - try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } } - try await fx.syncEngine.modifyRecordZones( - scope: .private, - deleting: [fx.zoneID] - ).notify() - try await fx.syncEngine.processPendingDatabaseChanges(scope: .private) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func zoneDeletionRemovesScopedOutRow() async throws { + try await userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await fx.userDatabase.read { db in - try #expect(ScopedModel.unscoped.fetchAll(db) == []) - } - } + try await syncEngine.modifyRecordZones( + scope: .private, + deleting: [syncEngine.defaultZone.zoneID] + ).notify() + try await syncEngine.processPendingDatabaseChanges(scope: .private) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func encryptedDataResetReuploadsScopedOutRow() async throws { - let fx = try await Self.makeFixture() - try await fx.userDatabase.userWrite { db in - try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } + try await userDatabase.read { db in + try #expect(ScopedModel.unscoped.fetchAll(db) == []) + } } - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) - try await fx.userDatabase.userWrite { db in - try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) - } - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) - await fx.syncEngine.handleEvent( - SyncEngine.Event.fetchedDatabaseChanges( - modifications: [], - deletions: [(fx.zoneID, .encryptedDataReset)] - ), - syncEngine: fx.syncEngine.private - ) - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func encryptedDataResetReuploadsScopedOutRow() async throws { + try await userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await fx.userDatabase.read { db in - try #expect(ScopedModel.unscoped.fetchAll(db).count == 1) - } - assertInlineSnapshot(of: fx.container.privateCloudDatabase, as: .customDump) { - """ - MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:scopedModels/zone/__defaultOwner__), - recordType: "scopedModels", - parent: nil, - share: nil, - id: 1, - isDeleted: 1, - title: "x" - ) - ] + await syncEngine.handleEvent( + SyncEngine.Event.fetchedDatabaseChanges( + modifications: [], + deletions: [(syncEngine.defaultZone.zoneID, .encryptedDataReset)] + ), + syncEngine: syncEngine.private ) - """ - } - } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverDeleteRemovesScopedOutRow() async throws { - let fx = try await Self.makeFixture() - try await fx.userDatabase.userWrite { db in - try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } - } - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) - try await fx.userDatabase.userWrite { db in - try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + try await userDatabase.read { db in + try #expect(ScopedModel.unscoped.fetchAll(db).count == 1) + } } - try await fx.syncEngine.modifyRecords( - scope: .private, - deleting: [ScopedModel.recordID(for: 1)] - ).notify() - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverDeleteRemovesScopedOutRow() async throws { + try await userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } - try await fx.userDatabase.read { db in - try #expect(ScopedModel.unscoped.fetchAll(db) == []) - } - } + try await syncEngine.modifyRecords( + scope: .private, + deleting: [ScopedModel.recordID(for: 1)] + ).notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverModificationMergesScopedOutRow() async throws { - let fx = try await Self.makeFixture() - try await fx.userDatabase.userWrite { db in - try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } - } - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) - try await fx.userDatabase.userWrite { db in - try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + try await userDatabase.read { db in + try #expect(ScopedModel.unscoped.fetchAll(db) == []) + } } - try await fx.syncEngine.processPendingRecordZoneChanges(scope: .private) - let serverRecord = CKRecord( - recordType: ScopedModel.tableName, - recordID: ScopedModel.recordID(for: 1) - ) - serverRecord["id"] = 1 - serverRecord["title"] = "from-server" - serverRecord["isDeleted"] = 1 - await fx.syncEngine.handleEvent( - SyncEngine.Event.fetchedRecordZoneChanges( - modifications: [serverRecord], - deletions: [] - ), - syncEngine: fx.syncEngine.private - ) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverModificationMergesScopedOutRow() async throws { + try await userDatabase.userWrite { db in + try db.seed { ScopedModel(id: 1, title: "x", isDeleted: false) } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await userDatabase.userWrite { db in + try ScopedModel.unscoped.find(1).update { $0.isDeleted = true }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let serverRecord = CKRecord( + recordType: ScopedModel.tableName, + recordID: ScopedModel.recordID(for: 1) + ) + serverRecord["id"] = 1 + serverRecord["title"] = "from-server" + serverRecord["isDeleted"] = 1 + await syncEngine.handleEvent( + SyncEngine.Event.fetchedRecordZoneChanges( + modifications: [serverRecord], + deletions: [] + ), + syncEngine: syncEngine.private + ) - try await fx.userDatabase.read { db in - let rows = try ScopedModel.unscoped.fetchAll(db) - #expect(rows.count == 1) - #expect(rows.first?.isDeleted == true) + try await userDatabase.read { db in + let rows = try ScopedModel.unscoped.fetchAll(db) + #expect(rows.count == 1) + #expect(rows.first?.isDeleted == true) + } } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index 4a04e1b2..e8d12d9f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -338,6 +338,35 @@ END """, [22]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_scopedModels_from_sync_engine" + AFTER DELETE ON "scopedModels" + 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") = ('scopedModels'))); + END + """, + [23]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_scopedModels_from_user" + AFTER DELETE ON "scopedModels" + 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.CloudKit.write-permission-error') + FROM "rootShares" + WHERE (((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) AND (("rootShares"."parentRecordName") IS (NULL))) AND (NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share")))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('scopedModels'))); + END + """, + [24]: """ CREATE TRIGGER "sqlitedata_icloud_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 @@ -357,7 +386,7 @@ )), "new"."share"); END """, - [23]: """ + [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_sync_engine" AFTER DELETE ON "tags" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -365,7 +394,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [24]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_user" AFTER DELETE ON "tags" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -386,7 +415,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [25]: """ + [27]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -412,7 +441,7 @@ ON CONFLICT DO NOTHING; END """, - [26]: """ + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -438,7 +467,7 @@ ON CONFLICT DO NOTHING; END """, - [27]: """ + [29]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" AFTER INSERT ON "modelAs" FOR EACH ROW BEGIN @@ -460,7 +489,7 @@ ON CONFLICT DO NOTHING; END """, - [28]: """ + [30]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" AFTER INSERT ON "modelBs" FOR EACH ROW BEGIN @@ -486,7 +515,7 @@ ON CONFLICT DO NOTHING; END """, - [29]: """ + [31]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" AFTER INSERT ON "modelCs" FOR EACH ROW BEGIN @@ -512,7 +541,7 @@ ON CONFLICT DO NOTHING; END """, - [30]: """ + [32]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN @@ -534,7 +563,7 @@ ON CONFLICT DO NOTHING; END """, - [31]: """ + [33]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN @@ -556,7 +585,7 @@ ON CONFLICT DO NOTHING; END """, - [32]: """ + [34]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN @@ -582,7 +611,7 @@ ON CONFLICT DO NOTHING; END """, - [33]: """ + [35]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" AFTER INSERT ON "remindersListAssets" FOR EACH ROW BEGIN @@ -608,7 +637,7 @@ ON CONFLICT DO NOTHING; END """, - [34]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -634,7 +663,7 @@ ON CONFLICT DO NOTHING; END """, - [35]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -656,7 +685,29 @@ ON CONFLICT DO NOTHING; END """, - [36]: """ + [38]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_scopedModels" + AFTER INSERT ON "scopedModels" + 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.CloudKit.write-permission-error') + FROM "rootShares" + WHERE (((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) AND (("rootShares"."parentRecordName") IS (NULL))) AND (NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share")))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'scopedModels', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + END + """, + [39]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -665,7 +716,7 @@ SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", "new"."zoneName", "new"."ownerName", "new"."zoneName", "new"."ownerName", NULL); END """, - [37]: """ + [40]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" AFTER INSERT ON "tags" FOR EACH ROW BEGIN @@ -687,7 +738,7 @@ ON CONFLICT DO NOTHING; END """, - [38]: """ + [41]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetDefaults" AFTER UPDATE OF "id" ON "childWithOnDeleteSetDefaults" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -708,7 +759,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetDefaults'))); END """, - [39]: """ + [42]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetNulls" AFTER UPDATE OF "id" ON "childWithOnDeleteSetNulls" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -729,7 +780,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetNulls'))); END """, - [40]: """ + [43]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelAs" AFTER UPDATE OF "id" ON "modelAs" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -750,7 +801,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelAs'))); END """, - [41]: """ + [44]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelBs" AFTER UPDATE OF "id" ON "modelBs" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -771,7 +822,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelBs'))); END """, - [42]: """ + [45]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelCs" AFTER UPDATE OF "id" ON "modelCs" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -792,7 +843,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelCs'))); END """, - [43]: """ + [46]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_parents" AFTER UPDATE OF "id" ON "parents" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -813,7 +864,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))); END """, - [44]: """ + [47]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminderTags" AFTER UPDATE OF "id" ON "reminderTags" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -834,7 +885,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [45]: """ + [48]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminders" AFTER UPDATE OF "id" ON "reminders" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -855,7 +906,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [46]: """ + [49]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListAssets" AFTER UPDATE OF "remindersListID" ON "remindersListAssets" FOR EACH ROW WHEN ("old"."remindersListID") <> ("new"."remindersListID") BEGIN @@ -876,7 +927,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [47]: """ + [50]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListPrivates" AFTER UPDATE OF "remindersListID" ON "remindersListPrivates" FOR EACH ROW WHEN ("old"."remindersListID") <> ("new"."remindersListID") BEGIN @@ -897,7 +948,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [48]: """ + [51]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersLists" AFTER UPDATE OF "id" ON "remindersLists" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -918,7 +969,28 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [49]: """ + [52]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_scopedModels" + AFTER UPDATE OF "id" ON "scopedModels" + FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") IS (NULL)) AND (("sqlitedata_icloud_metadata"."recordType") IS (NULL))) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName") IS ("rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE (((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) AND (("rootShares"."parentRecordName") IS (NULL))) AND (NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share")))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('scopedModels'))); + END + """, + [53]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_tags" AFTER UPDATE OF "title" ON "tags" FOR EACH ROW WHEN ("old"."title") <> ("new"."title") BEGIN @@ -939,7 +1011,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [50]: """ + [54]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -972,7 +1044,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetDefaults'))); END """, - [51]: """ + [55]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -1005,7 +1077,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetNulls'))); END """, - [52]: """ + [56]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" AFTER UPDATE ON "modelAs" FOR EACH ROW BEGIN @@ -1030,7 +1102,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelAs'))); END """, - [53]: """ + [57]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" AFTER UPDATE ON "modelBs" FOR EACH ROW BEGIN @@ -1063,7 +1135,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelBs'))); END """, - [54]: """ + [58]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" AFTER UPDATE ON "modelCs" FOR EACH ROW BEGIN @@ -1096,7 +1168,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelCs'))); END """, - [55]: """ + [59]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -1121,7 +1193,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))); END """, - [56]: """ + [60]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -1146,7 +1218,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [57]: """ + [61]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -1179,7 +1251,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [58]: """ + [62]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -1212,7 +1284,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [59]: """ + [63]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -1245,7 +1317,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [60]: """ + [64]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -1270,7 +1342,32 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [61]: """ + [65]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_scopedModels" + AFTER UPDATE ON "scopedModels" + 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.CloudKit.write-permission-error') + FROM "rootShares" + WHERE (((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) AND (("rootShares"."parentRecordName") IS (NULL))) AND (NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share")))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'scopedModels', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce("sqlitedata_icloud_currentZoneName"(), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce("sqlitedata_icloud_currentOwnerName"(), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = NULL, "parentRecordType" = NULL, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('scopedModels'))); + END + """, + [66]: """ CREATE TRIGGER "sqlitedata_icloud_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 @@ -1292,7 +1389,7 @@ ) END); END """, - [62]: """ + [67]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN @@ -1317,7 +1414,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [63]: """ + [68]: """ CREATE TRIGGER "sqlitedata_icloud_after_zone_update_on_sqlitedata_icloud_metadata" AFTER UPDATE OF "zoneName", "ownerName" ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN (("new"."zoneName") <> ("old"."zoneName")) OR (("new"."ownerName") <> ("old"."ownerName")) BEGIN diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index d54dc32b..8dd00241 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -79,6 +79,7 @@ ModelA.self, ModelB.self, ModelC.self, + ScopedModel.self, privateTables: RemindersListPrivate.self, startImmediately: _StartImmediatelyTrait.startImmediately )