diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index c03123a1..0cae9633 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -130,7 +130,7 @@ @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecordKeyValueSetting { - fileprivate subscript(at key: String) -> Int64 { + package subscript(at key: String) -> Int64 { get { self["\(CKRecord.userModificationTimeKey)_\(key)"] as? Int64 ?? -1 } diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index d7332fc9..8a21c1c9 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -365,6 +365,47 @@ type: "TEXT" ) ] + ), + [12]: RecordType( + tableName: "posts", + schema: """ + CREATE TABLE "posts" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "body" TEXT, + "isPublished" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "body", + isNotNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INT" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isPublished", + isNotNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] ) ] """# diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 34530951..dd7297bf 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -11,727 +11,1797 @@ extension BaseCloudKitTests { @MainActor - @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable - { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { + @Suite(.attachMetadatabase, .printTimestamps) + final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { + + // MARK: - Different Fields Change + + @Test func differentFieldsChange_conflictOnSend_clientNewer() async throws { + // Step 1: Seed and initial sync try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + try db.seed { Post(id: 1, title: "") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits isPublished @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.isPublished = true }.execute(db) } } + + // Step 4: Send (rejected, merged locally) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 30, + 🗓️: 30 + ) + ] ) """ } - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 60) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello", │ + │ body: nil, │ + │ isPublished: true │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 1, + isPublished🗓️: 60, + title: "Hello", + title🗓️: 30, + 🗓️: 60 + ) + ] + ) + """ + } + } + + @Test func differentFieldsChange_conflictOnSend_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits isPublished @ t=30 try await withDependencies { - $0.currentTime.now += 30 + $0.currentTime.now = 30 } operation: { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + try Post.find(1).update { $0.isPublished = true }.execute(db) } } + + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Send (rejected, merged locally) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modificationCallback.notify() - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 1, - isCompleted🗓️: 30, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 60, + 🗓️: 60 + ) + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello", │ + │ body: nil, │ + │ isPublished: true │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └────────────────────────────┘ + """ + } + // NB: t_isPublished is 60 (not 30), because all changed fields are sent with the user + // modification time, which is set to max(t_client, t_server). + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 1, + isPublished🗓️: 60, + title: "Hello", + title🗓️: 60, + 🗓️: 60 + ) + ] ) """ } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordUpdatedBeforeClientRecord() async throws { + @Test func differentFieldsChange_conflictOnFetch_clientNewer() async throws { + // Step 1: Seed and initial sync try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + try db.seed { Post(id: 1, title: "") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 30, + 🗓️: 30 + ) + ] + ) + """ + } + + // Step 3: Client edits isPublished @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.isPublished = true }.execute(db) } } + + // Step 4: Fetch arrives (merged locally) + await fetchedRecordZoneChangesCallback.notify() + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 30, + 🗓️: 30 + ) + ] + ) + """ + } + + // Step 5: Send (merged record) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello", │ + │ body: nil, │ + │ isPublished: true │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 1, + isPublished🗓️: 60, + title: "Hello", + title🗓️: 30, + 🗓️: 60 + ) + ] ) """ } + } - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 30) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() + @Test func differentFieldsChange_conflictOnFetch_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits isPublished @ t=30 try await withDependencies { - $0.currentTime.now += 60 + $0.currentTime.now = 30 } operation: { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + try Post.find(1).update { $0.isPublished = true }.execute(db) } } + + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 60, + 🗓️: 60 + ) + ] + ) + """ + } + + // Step 4: Fetch arrives (merged locally) + await fetchedRecordZoneChangesCallback.notify() + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 60, + 🗓️: 60 + ) + ] + ) + """ + } + + // Step 5: Send (merged record) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 30, - 🗓️: 30 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modificationCallback.notify() - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 1, - isCompleted🗓️: 60, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 30, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello", │ + │ body: nil, │ + │ isPublished: true │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 1, + isPublished🗓️: 60, + title: "Hello", + title🗓️: 60, + 🗓️: 60 + ) + ] ) """ } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverAndClientEditDifferentFields() async throws { + @Test func differentNullableFieldsChange_conflictOnFetch_clientNewer() async throws { + // Step 1: Seed and initial sync try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, remindersListID: 1) } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) + // Step 2: Server changes dueDate @ t=30 let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 30) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() + record.setValue(Date(timeIntervalSince1970: 30), forKey: "dueDate", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + // Step 3: Client changes priority @ t=60 try await withDependencies { - $0.currentTime.now += 60 + $0.currentTime.now = 60 } operation: { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted = true }.execute(db) + try Reminder.find(1).update { $0.priority = #bind(3) }.execute(db) } } - await modificationCallback.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 1, - isCompleted🗓️: 60, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Buy milk", - title🗓️: 30, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + + // Step 4: Fetch arrives (conflict, merged locally) + await fetchedRecordZoneChangesCallback.notify() + + // Step 5: Send (merged result) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery( + Reminder.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌──────────────────────────────────────────────┐ + │ SyncedRow( │ + │ row: Reminder( │ + │ id: 1, │ + │ dueDate: Date(1970-01-01T00:00:30.000Z), │ + │ isCompleted: false, │ + │ priority: 3, │ + │ title: "", │ + │ remindersListID: 1 │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └──────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + dueDate: Date(1970-01-01T00:00:30.000Z), + dueDate🗓️: 30, + id: 1, + id🗓️: 0, + isCompleted: 0, + isCompleted🗓️: 0, + priority: 3, + priority🗓️: 60, + remindersListID: 1, + remindersListID🗓️: 0, + title: "", + title🗓️: 0, + 🗓️: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + id🗓️: 0, + title: "Personal", + title🗓️: 0, + 🗓️: 0 + ) + ] ) """ } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordEditedAfterClientButProcessedBeforeClient() async throws { + // MARK: - Same Field Change + + @Test func sameFieldChange_conflictOnSend_retryBeforeFetch_clientNewer() async throws { + // Step 1: Seed and initial sync try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) - } + try db.seed { Post(id: 1, title: "Hello") } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits title @ t=60 try await withDependencies { - $0.currentTime.now += 30 + $0.currentTime.now = 60 } operation: { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) } - try await withDependencies { - $0.currentTime.now += 30 - } operation: { - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: now) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - await modificationCallback.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from server", + title🗓️: 30, + 🗓️: 30 + ) + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌─────────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello from client", │ + │ body: nil, │ + │ isPublished: false │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └─────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from client", + title🗓️: 60, + 🗓️: 60 + ) + ] + ) + """ + } + } + + @Test func sameFieldChange_conflictOnSend_retryBeforeFetch_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits title @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) } } - assertQuery(Reminder.all, database: userDatabase.database) { - """ - ┌───────────────────────┐ - │ Reminder( │ - │ id: 1, │ - │ dueDate: nil, │ - │ isCompleted: false, │ - │ priority: nil, │ - │ title: "Get milk", │ - │ remindersListID: 1 │ - │ ) │ - └───────────────────────┘ - """ - } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Get milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from server", + title🗓️: 60, + 🗓️: 60 + ) + ] ) """ } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.title == "Hello from server") + } + } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordEditedAndProcessedBeforeClient() async throws { + @Test func sameFieldChange_conflictOnSend_fetchBeforeRetry_clientNewer() async throws { + // Step 1: Seed and initial sync try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits title @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) } } + + // Step 4: Send (rejected, merged locally) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 30) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from server", + title🗓️: 30, + 🗓️: 30 + ) + ] + ) + """ + } + + // Step 5: Fetch arrives + await fetchedRecordZoneChangesCallback.notify() + // Step 6: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌─────────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello from client", │ + │ body: nil, │ + │ isPublished: false │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └─────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from client", + title🗓️: 60, + 🗓️: 60 + ) + ] + ) + """ + } + } + + @Test func sameFieldChange_conflictOnSend_fetchBeforeRetry_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits title @ t=30 try await withDependencies { - $0.currentTime.now += 60 + $0.currentTime.now = 30 } operation: { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) } } - await modificationCallback.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Get milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from server", + title🗓️: 60, + 🗓️: 60 + ) + ] ) """ } + + // Step 5: Fetch arrives + await fetchedRecordZoneChangesCallback.notify() + + // Step 6: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.title == "Hello from server") + } + } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordEditedBeforeClientButProcessedAfterClient() async throws { + @Test func sameFieldChange_conflictOnSend_equalTimestamps() async throws { + // Step 1: Seed and initial sync try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 2: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits title @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) } } + + // Step 4: Send (rejected, merged locally) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 30) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌─────────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello from client", │ + │ body: nil, │ + │ isPublished: false │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └─────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from client", + title🗓️: 60, + 🗓️: 60 + ) + ] + ) + """ + } + } + + @Test func sameFieldChange_conflictOnFetch_clientNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + // Step 3: Client edits title @ t=60 try await withDependencies { - $0.currentTime.now += 60 + $0.currentTime.now = 60 } operation: { try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) } } + + // Step 4: Fetch arrives (conflict, merged locally) + await fetchedRecordZoneChangesCallback.notify() + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from server", + title🗓️: 30, + 🗓️: 30 + ) + ] + ) + """ + } + + // Step 5: Send (merged result) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modificationCallback.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority🗓️: 0, - remindersListID: 1, - remindersListID🗓️: 0, - title: "Get milk", - title🗓️: 60, - 🗓️: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌─────────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello from client", │ + │ body: nil, │ + │ isPublished: false │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └─────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from client", + title🗓️: 60, + 🗓️: 60 + ) + ] ) """ } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func mergeWithNullableFields() async throws { + @Test func sameFieldChange_conflictOnFetch_serverNewer() async throws { + // Step 1: Seed and initial sync try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, remindersListID: 1) + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits title @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) } } + + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Fetch arrives (conflict, merged locally) + await fetchedRecordZoneChangesCallback.notify() + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello from server", + title🗓️: 60, + 🗓️: 60 + ) + ] + ) + """ + } + + // Step 5: Send (merged result) try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.title == "Hello from server") + } + } + } + + // MARK: - Same Field Change & Removal + + @Test func sameFieldChangeAndRemoval_conflictOnSend_clientNewer() async throws { + // Step 1: Seed with body and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello", body: "Original body") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body: "Original body", + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Server changes body @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Server body", forKey: "body", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client nulls body @ t=60 try await withDependencies { - $0.currentTime.now += 1 + $0.currentTime.now = 60 } operation: { - let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: 1) - ) - reminderRecord.setValue( - Date(timeIntervalSince1970: Double(30)), - forKey: "dueDate", - at: now - ) - let modificationsFinished = try syncEngine.modifyRecords( - scope: .private, - saving: [reminderRecord] - ) - - try await withDependencies { - $0.currentTime.now += 1 - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.priority = #bind(3) }.execute(db) - } - await modificationsFinished.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.body = #bind(nil as String?) }.execute(db) } + } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate: Date(1970-01-01T00:00:30.000Z), - dueDate🗓️: 1, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority: 3, - priority🗓️: 2, - remindersListID: 1, - remindersListID🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 2 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - id🗓️: 0, - title: "Personal", - title🗓️: 0, - 🗓️: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body: "Server body", + body🗓️: 30, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 30 ) - ) - """ + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello", │ + │ body: nil, │ + │ isPublished: false │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 60, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 60 + ) + ] + ) + """ + } + } + + @Test func sameFieldChangeAndRemoval_conflictOnSend_serverNewer() async throws { + // Step 1: Seed with body and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello", body: "Original body") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body: "Original body", + body🗓️: 0, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 0 + ) + ] + ) + """ + } + + // Step 2: Client nulls body @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.body = #bind(nil as String?) }.execute(db) } + } - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - expectNoDifference( - reminder, - Reminder( + // Step 3: Server changes body @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Server body", forKey: "body", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body: "Server body", + body🗓️: 60, id: 1, - dueDate: Date(timeIntervalSince1970: 30), - priority: 3, - remindersListID: 1 + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 60 ) - ) + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.body == "Server body") + } + } + } + + @Test func sameFieldRemoval_conflictOnSend_clientNewer() async throws { + // Step 1: Seed with body and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello", body: "Original body") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 2: Server nulls body @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.removeValue(forKey: "body", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client nulls body @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.body = #bind(nil as String?) }.execute(db) + } + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello", │ + │ body: nil, │ + │ isPublished: false │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └────────────────────────────┘ + """ + } + withKnownIssue("Per-field timestamp should reflect the newer removal") { + let recordID = Post.recordID(for: 1) + let record = try syncEngine.private.database.record(for: recordID) + #expect(record.encryptedValues[at: "body"] == 60) + } + } + + @Test func sameFieldRemoval_conflictOnSend_serverNewer() async throws { + // Step 1: Seed with body and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello", body: "Original body") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 2: Server nulls body @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.removeValue(forKey: "body", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client nulls body @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.body = #bind(nil as String?) }.execute(db) } } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + ┌────────────────────────────┐ + │ SyncedRow( │ + │ row: Post( │ + │ id: 1, │ + │ title: "Hello", │ + │ body: nil, │ + │ isPublished: false │ + │ ), │ + │ userModificationTime: 60 │ + │ ) │ + └────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body🗓️: 60, + id: 1, + id🗓️: 0, + isPublished: 0, + isPublished🗓️: 0, + title: "Hello", + title🗓️: 0, + 🗓️: 60 + ) + ] + ) + """ + } } } } + + @Selection struct SyncedRow where T.QueryOutput == T { + let row: T + let userModificationTime: Int64 + } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 8e624b72..3cfb0b5e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -363,6 +363,47 @@ type: "TEXT" ) ] + ), + [12]: RecordType( + tableName: "posts", + schema: """ + CREATE TABLE "posts" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "body" TEXT, + "isPublished" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "body", + isNotNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INT" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isPublished", + isNotNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] ) ] """# diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index 4a04e1b2..7a2a8215 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -193,6 +193,35 @@ END """, [12]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_posts_from_sync_engine" + AFTER DELETE ON "posts" + 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") = ('posts'))); + END + """, + [13]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_posts_from_user" + AFTER DELETE ON "posts" + 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") = ('posts'))); + END + """, + [14]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_sync_engine" AFTER DELETE ON "reminderTags" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -200,7 +229,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [13]: """ + [15]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_user" AFTER DELETE ON "reminderTags" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -221,7 +250,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [14]: """ + [16]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_sync_engine" AFTER DELETE ON "remindersListAssets" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -229,7 +258,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [15]: """ + [17]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_user" AFTER DELETE ON "remindersListAssets" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -250,7 +279,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [16]: """ + [18]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_sync_engine" AFTER DELETE ON "remindersListPrivates" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -258,7 +287,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [17]: """ + [19]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_user" AFTER DELETE ON "remindersListPrivates" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -279,7 +308,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [18]: """ + [20]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_sync_engine" AFTER DELETE ON "remindersLists" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -287,7 +316,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [19]: """ + [21]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_user" AFTER DELETE ON "remindersLists" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -308,7 +337,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [20]: """ + [22]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_sync_engine" AFTER DELETE ON "reminders" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -316,7 +345,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [21]: """ + [23]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_user" AFTER DELETE ON "reminders" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -337,7 +366,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [22]: """ + [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,29 @@ ON CONFLICT DO NOTHING; END """, - [31]: """ + [33]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_posts" + AFTER INSERT ON "posts" + 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", 'posts', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO NOTHING; + END + """, + [34]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN @@ -556,7 +607,7 @@ ON CONFLICT DO NOTHING; END """, - [32]: """ + [35]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN @@ -582,7 +633,7 @@ ON CONFLICT DO NOTHING; END """, - [33]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" AFTER INSERT ON "remindersListAssets" FOR EACH ROW BEGIN @@ -608,7 +659,7 @@ ON CONFLICT DO NOTHING; END """, - [34]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -634,7 +685,7 @@ ON CONFLICT DO NOTHING; END """, - [35]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -656,7 +707,7 @@ ON CONFLICT DO NOTHING; END """, - [36]: """ + [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,28 @@ 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_posts" + AFTER UPDATE OF "id" ON "posts" + 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") = ('posts'))); + END + """, + [48]: """ 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 +906,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [45]: """ + [49]: """ 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 +927,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [46]: """ + [50]: """ 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 +948,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [47]: """ + [51]: """ 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 +969,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [48]: """ + [52]: """ 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 +990,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [49]: """ + [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,32 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))); END """, - [56]: """ + [60]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_posts" + AFTER UPDATE ON "posts" + 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", 'posts', 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") = ('posts'))); + END + """, + [61]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -1146,7 +1243,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [57]: """ + [62]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -1179,7 +1276,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [58]: """ + [63]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -1212,7 +1309,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [59]: """ + [64]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -1245,7 +1342,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [60]: """ + [65]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -1270,7 +1367,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [61]: """ + [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..c7b294d8 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -79,6 +79,7 @@ ModelA.self, ModelB.self, ModelC.self, + Post.self, privateTables: RemindersListPrivate.self, startImmediately: _StartImmediatelyTrait.startImmediately ) diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index a9e965b9..a63a11c7 100644 --- a/Tests/SQLiteDataTests/Internal/Schema.swift +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -70,6 +70,12 @@ import SQLiteData var title = "" var modelBID: ModelB.ID } +@Table struct Post: Equatable, Identifiable { + let id: Int + var title: String + var body: String? + var isPublished = false +} @Table struct UnsyncedModel: Equatable, Identifiable { let id: Int } @@ -220,6 +226,17 @@ func database( """ ) .execute(db) + try #sql( + """ + CREATE TABLE "posts" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "body" TEXT, + "isPublished" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """ + ) + .execute(db) try #sql( """ CREATE TABLE "unsyncedModels" (